@youam/sdk 0.1.0 → 0.2.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/LICENSE +190 -0
- package/README.md +149 -7
- package/dist/cli/index.cjs +3345 -0
- package/dist/index.cjs +2974 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1095 -0
- package/dist/index.d.ts +1095 -0
- package/dist/index.js +2946 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -14
- package/index.js +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,2946 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/sdk/dns-verifier.ts
|
|
12
|
+
var dns_verifier_exports = {};
|
|
13
|
+
__export(dns_verifier_exports, {
|
|
14
|
+
extractPublicKey: () => extractPublicKey,
|
|
15
|
+
generateTxtRecord: () => generateTxtRecord,
|
|
16
|
+
parseUamTxt: () => parseUamTxt,
|
|
17
|
+
queryUamTxt: () => queryUamTxt,
|
|
18
|
+
resolveKeyViaHttps: () => resolveKeyViaHttps
|
|
19
|
+
});
|
|
20
|
+
import { resolveTxt as dnsResolveTxt } from "dns/promises";
|
|
21
|
+
function parseUamTxt(txtValue) {
|
|
22
|
+
const tags = {};
|
|
23
|
+
for (const part of txtValue.split(";")) {
|
|
24
|
+
const trimmed = part.trim();
|
|
25
|
+
if (!trimmed) continue;
|
|
26
|
+
const eqIdx = trimmed.indexOf("=");
|
|
27
|
+
if (eqIdx !== -1) {
|
|
28
|
+
const tag = trimmed.slice(0, eqIdx).trim().toLowerCase();
|
|
29
|
+
const value = trimmed.slice(eqIdx + 1).trim();
|
|
30
|
+
tags[tag] = value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return tags;
|
|
34
|
+
}
|
|
35
|
+
function extractPublicKey(tags) {
|
|
36
|
+
const keyValue = tags["key"] ?? "";
|
|
37
|
+
if (keyValue.startsWith("ed25519:")) {
|
|
38
|
+
return keyValue.slice("ed25519:".length);
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
async function queryUamTxt(domain, timeout) {
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const timer = timeout != null ? setTimeout(() => controller.abort(), timeout) : void 0;
|
|
45
|
+
try {
|
|
46
|
+
const records = await dnsResolveTxt(`_uam.${domain}`);
|
|
47
|
+
const results = [];
|
|
48
|
+
for (const parts of records) {
|
|
49
|
+
const txtValue = parts.join("");
|
|
50
|
+
if (txtValue.trim().startsWith("v=uam1")) {
|
|
51
|
+
results.push(txtValue);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return results;
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
} finally {
|
|
58
|
+
if (timer != null) clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function resolveKeyViaHttps(agentName, domain, timeout) {
|
|
62
|
+
const url = `https://${domain}/.well-known/uam.json`;
|
|
63
|
+
try {
|
|
64
|
+
const resp = await fetch(url, {
|
|
65
|
+
signal: AbortSignal.timeout(timeout ?? 1e4)
|
|
66
|
+
});
|
|
67
|
+
if (!resp.ok) return null;
|
|
68
|
+
const data = await resp.json();
|
|
69
|
+
if (data["v"] !== "uam1") return null;
|
|
70
|
+
const agents = data["agents"] ?? {};
|
|
71
|
+
const entry = agents[agentName];
|
|
72
|
+
if (!entry) return null;
|
|
73
|
+
let keyValue = entry["key"] ?? "";
|
|
74
|
+
if (keyValue.startsWith("ed25519:")) {
|
|
75
|
+
keyValue = keyValue.slice("ed25519:".length);
|
|
76
|
+
}
|
|
77
|
+
return keyValue || null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function generateTxtRecord(publicKey, relayUrl) {
|
|
83
|
+
return `v=uam1; key=ed25519:${publicKey}; relay=${relayUrl}`;
|
|
84
|
+
}
|
|
85
|
+
var init_dns_verifier = __esm({
|
|
86
|
+
"src/sdk/dns-verifier.ts"() {
|
|
87
|
+
"use strict";
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// src/sdk/agent.ts
|
|
92
|
+
import { join as join4 } from "path";
|
|
93
|
+
|
|
94
|
+
// src/protocol/index.ts
|
|
95
|
+
var protocol_exports = {};
|
|
96
|
+
__export(protocol_exports, {
|
|
97
|
+
DecryptionError: () => DecryptionError,
|
|
98
|
+
EncryptionError: () => EncryptionError,
|
|
99
|
+
EnvelopeTooLargeError: () => EnvelopeTooLargeError,
|
|
100
|
+
InvalidAddressError: () => InvalidAddressError,
|
|
101
|
+
InvalidContactCardError: () => InvalidContactCardError,
|
|
102
|
+
InvalidEnvelopeError: () => InvalidEnvelopeError,
|
|
103
|
+
KeyPinningError: () => KeyPinningError,
|
|
104
|
+
MAX_ENVELOPE_AGE: () => MAX_ENVELOPE_AGE,
|
|
105
|
+
MAX_ENVELOPE_SIZE: () => MAX_ENVELOPE_SIZE,
|
|
106
|
+
MessageType: () => MessageType,
|
|
107
|
+
SignatureError: () => SignatureError,
|
|
108
|
+
SignatureVerificationError: () => SignatureVerificationError,
|
|
109
|
+
UAMError: () => UAMError,
|
|
110
|
+
UAM_VERSION: () => UAM_VERSION,
|
|
111
|
+
b64Decode: () => b64Decode,
|
|
112
|
+
b64Encode: () => b64Encode,
|
|
113
|
+
canonicalize: () => canonicalize,
|
|
114
|
+
contactCardFromDict: () => contactCardFromDict,
|
|
115
|
+
contactCardToDict: () => contactCardToDict,
|
|
116
|
+
createContactCard: () => createContactCard,
|
|
117
|
+
createEnvelope: () => createEnvelope,
|
|
118
|
+
decryptPayload: () => decryptPayload,
|
|
119
|
+
decryptPayloadAnonymous: () => decryptPayloadAnonymous,
|
|
120
|
+
deserializeSigningKey: () => deserializeSigningKey,
|
|
121
|
+
deserializeVerifyKey: () => deserializeVerifyKey,
|
|
122
|
+
encryptPayload: () => encryptPayload,
|
|
123
|
+
encryptPayloadAnonymous: () => encryptPayloadAnonymous,
|
|
124
|
+
fromWireDict: () => fromWireDict,
|
|
125
|
+
generateKeypair: () => generateKeypair,
|
|
126
|
+
generateNonce: () => generateNonce,
|
|
127
|
+
parseAddress: () => parseAddress,
|
|
128
|
+
publicKeyFingerprint: () => publicKeyFingerprint,
|
|
129
|
+
serializeSigningKey: () => serializeSigningKey,
|
|
130
|
+
serializeVerifyKey: () => serializeVerifyKey,
|
|
131
|
+
signMessage: () => signMessage,
|
|
132
|
+
sodiumReady: () => sodiumReady,
|
|
133
|
+
toWireDict: () => toWireDict,
|
|
134
|
+
utcTimestamp: () => utcTimestamp,
|
|
135
|
+
validateEnvelopeSize: () => validateEnvelopeSize,
|
|
136
|
+
verifyContactCard: () => verifyContactCard,
|
|
137
|
+
verifyEnvelope: () => verifyEnvelope,
|
|
138
|
+
verifySignature: () => verifySignature
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// src/protocol/types.ts
|
|
142
|
+
var UAM_VERSION = "0.1";
|
|
143
|
+
var MAX_ENVELOPE_SIZE = 65536;
|
|
144
|
+
var MAX_ENVELOPE_AGE = 300;
|
|
145
|
+
var MessageType = /* @__PURE__ */ ((MessageType2) => {
|
|
146
|
+
MessageType2["MESSAGE"] = "message";
|
|
147
|
+
MessageType2["HANDSHAKE_REQUEST"] = "handshake.request";
|
|
148
|
+
MessageType2["HANDSHAKE_ACCEPT"] = "handshake.accept";
|
|
149
|
+
MessageType2["HANDSHAKE_DENY"] = "handshake.deny";
|
|
150
|
+
MessageType2["RECEIPT_DELIVERED"] = "receipt.delivered";
|
|
151
|
+
MessageType2["RECEIPT_READ"] = "receipt.read";
|
|
152
|
+
MessageType2["RECEIPT_FAILED"] = "receipt.failed";
|
|
153
|
+
MessageType2["SESSION_REQUEST"] = "session.request";
|
|
154
|
+
MessageType2["SESSION_ACCEPT"] = "session.accept";
|
|
155
|
+
MessageType2["SESSION_DECLINE"] = "session.decline";
|
|
156
|
+
MessageType2["SESSION_END"] = "session.end";
|
|
157
|
+
return MessageType2;
|
|
158
|
+
})(MessageType || {});
|
|
159
|
+
function b64Encode(data) {
|
|
160
|
+
return Buffer.from(data).toString("base64url");
|
|
161
|
+
}
|
|
162
|
+
function b64Decode(s) {
|
|
163
|
+
return new Uint8Array(Buffer.from(s, "base64url"));
|
|
164
|
+
}
|
|
165
|
+
function utcTimestamp() {
|
|
166
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/protocol/errors.ts
|
|
170
|
+
var UAMError = class extends Error {
|
|
171
|
+
constructor(message) {
|
|
172
|
+
super(message);
|
|
173
|
+
this.name = "UAMError";
|
|
174
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
var InvalidAddressError = class extends UAMError {
|
|
178
|
+
constructor(message) {
|
|
179
|
+
super(message);
|
|
180
|
+
this.name = "InvalidAddressError";
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
var InvalidEnvelopeError = class extends UAMError {
|
|
184
|
+
constructor(message) {
|
|
185
|
+
super(message);
|
|
186
|
+
this.name = "InvalidEnvelopeError";
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
var EnvelopeTooLargeError = class extends InvalidEnvelopeError {
|
|
190
|
+
constructor(message) {
|
|
191
|
+
super(message);
|
|
192
|
+
this.name = "EnvelopeTooLargeError";
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
var SignatureError = class extends UAMError {
|
|
196
|
+
constructor(message) {
|
|
197
|
+
super(message);
|
|
198
|
+
this.name = "SignatureError";
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
var SignatureVerificationError = class extends SignatureError {
|
|
202
|
+
constructor(message) {
|
|
203
|
+
super(message);
|
|
204
|
+
this.name = "SignatureVerificationError";
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
var EncryptionError = class extends UAMError {
|
|
208
|
+
constructor(message) {
|
|
209
|
+
super(message);
|
|
210
|
+
this.name = "EncryptionError";
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
var DecryptionError = class extends EncryptionError {
|
|
214
|
+
constructor(message) {
|
|
215
|
+
super(message);
|
|
216
|
+
this.name = "DecryptionError";
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
var InvalidContactCardError = class extends UAMError {
|
|
220
|
+
constructor(message) {
|
|
221
|
+
super(message);
|
|
222
|
+
this.name = "InvalidContactCardError";
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
var KeyPinningError = class _KeyPinningError extends UAMError {
|
|
226
|
+
constructor(message) {
|
|
227
|
+
super(message);
|
|
228
|
+
this.name = "KeyPinningError";
|
|
229
|
+
Object.setPrototypeOf(this, _KeyPinningError.prototype);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// src/protocol/address.ts
|
|
234
|
+
var ADDRESS_RE = /^(?<agent>[a-z0-9][a-z0-9_-]{0,62}[a-z0-9]|[a-z0-9])::(?<domain>[a-z0-9](?:[a-z0-9.-]{0,253}[a-z0-9])?)$/;
|
|
235
|
+
var MAX_AGENT_LEN = 64;
|
|
236
|
+
var MAX_ADDRESS_LEN = 128;
|
|
237
|
+
function parseAddress(raw) {
|
|
238
|
+
const normalized = raw.trim().toLowerCase();
|
|
239
|
+
if (normalized.length > MAX_ADDRESS_LEN) {
|
|
240
|
+
throw new InvalidAddressError(
|
|
241
|
+
`Address exceeds ${MAX_ADDRESS_LEN} characters: ${JSON.stringify(raw)}`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
const m = ADDRESS_RE.exec(normalized);
|
|
245
|
+
if (!m || !m.groups) {
|
|
246
|
+
throw new InvalidAddressError(
|
|
247
|
+
`Invalid UAM address: ${JSON.stringify(raw)}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const agent = m.groups.agent;
|
|
251
|
+
if (agent.length > MAX_AGENT_LEN) {
|
|
252
|
+
throw new InvalidAddressError(
|
|
253
|
+
`Agent name exceeds ${MAX_AGENT_LEN} characters: ${JSON.stringify(raw)}`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
agent,
|
|
258
|
+
domain: m.groups.domain,
|
|
259
|
+
get full() {
|
|
260
|
+
return `${this.agent}::${this.domain}`;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/protocol/crypto.ts
|
|
266
|
+
import sodium from "libsodium-wrappers";
|
|
267
|
+
import { createHash } from "crypto";
|
|
268
|
+
var sodiumReady = sodium.ready;
|
|
269
|
+
function generateKeypair() {
|
|
270
|
+
const seed = sodium.randombytes_buf(32);
|
|
271
|
+
const kp = sodium.crypto_sign_seed_keypair(seed);
|
|
272
|
+
return {
|
|
273
|
+
signingKey: kp.privateKey,
|
|
274
|
+
verifyKey: kp.publicKey,
|
|
275
|
+
seed
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function serializeSigningKey(seed) {
|
|
279
|
+
return b64Encode(seed);
|
|
280
|
+
}
|
|
281
|
+
function deserializeSigningKey(s) {
|
|
282
|
+
const seed = b64Decode(s);
|
|
283
|
+
const kp = sodium.crypto_sign_seed_keypair(seed);
|
|
284
|
+
return {
|
|
285
|
+
signingKey: kp.privateKey,
|
|
286
|
+
verifyKey: kp.publicKey,
|
|
287
|
+
seed
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function serializeVerifyKey(key) {
|
|
291
|
+
return b64Encode(key);
|
|
292
|
+
}
|
|
293
|
+
function deserializeVerifyKey(s) {
|
|
294
|
+
return b64Decode(s);
|
|
295
|
+
}
|
|
296
|
+
function publicKeyFingerprint(verifyKey) {
|
|
297
|
+
return createHash("sha256").update(Buffer.from(verifyKey)).digest("hex");
|
|
298
|
+
}
|
|
299
|
+
function sortedStringify(value) {
|
|
300
|
+
if (value === null || value === void 0) {
|
|
301
|
+
return "null";
|
|
302
|
+
}
|
|
303
|
+
if (typeof value === "string") {
|
|
304
|
+
return JSON.stringify(value);
|
|
305
|
+
}
|
|
306
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
307
|
+
return String(value);
|
|
308
|
+
}
|
|
309
|
+
if (Array.isArray(value)) {
|
|
310
|
+
const items = value.map((v) => sortedStringify(v));
|
|
311
|
+
return "[" + items.join(",") + "]";
|
|
312
|
+
}
|
|
313
|
+
if (typeof value === "object") {
|
|
314
|
+
const obj = value;
|
|
315
|
+
const keys = Object.keys(obj).sort();
|
|
316
|
+
const pairs = keys.map((k) => JSON.stringify(k) + ":" + sortedStringify(obj[k]));
|
|
317
|
+
return "{" + pairs.join(",") + "}";
|
|
318
|
+
}
|
|
319
|
+
return String(value);
|
|
320
|
+
}
|
|
321
|
+
function canonicalize(data) {
|
|
322
|
+
const filtered = {};
|
|
323
|
+
for (const [k, v] of Object.entries(data)) {
|
|
324
|
+
if (k !== "signature" && v !== null && v !== void 0) {
|
|
325
|
+
filtered[k] = v;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const json = sortedStringify(filtered);
|
|
329
|
+
return new TextEncoder().encode(json);
|
|
330
|
+
}
|
|
331
|
+
function signMessage(data, signingKey) {
|
|
332
|
+
const signature = sodium.crypto_sign_detached(data, signingKey);
|
|
333
|
+
return b64Encode(signature);
|
|
334
|
+
}
|
|
335
|
+
function verifySignature(data, signatureB64, verifyKey) {
|
|
336
|
+
try {
|
|
337
|
+
const sigBytes = b64Decode(signatureB64);
|
|
338
|
+
const valid = sodium.crypto_sign_verify_detached(sigBytes, data, verifyKey);
|
|
339
|
+
if (!valid) {
|
|
340
|
+
throw new SignatureVerificationError("Signature verification failed");
|
|
341
|
+
}
|
|
342
|
+
} catch (err) {
|
|
343
|
+
if (err instanceof SignatureVerificationError) {
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
throw new SignatureVerificationError(
|
|
347
|
+
err instanceof Error ? err.message : String(err)
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function generateNonce() {
|
|
352
|
+
return b64Encode(sodium.randombytes_buf(24));
|
|
353
|
+
}
|
|
354
|
+
function encryptPayload(plaintext, senderSigningKey, recipientVerifyKey) {
|
|
355
|
+
try {
|
|
356
|
+
const senderCurve = sodium.crypto_sign_ed25519_sk_to_curve25519(senderSigningKey);
|
|
357
|
+
const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(recipientVerifyKey);
|
|
358
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
|
359
|
+
const encrypted = sodium.crypto_box_easy(plaintext, nonce, recipientCurve, senderCurve);
|
|
360
|
+
const combined = new Uint8Array(nonce.length + encrypted.length);
|
|
361
|
+
combined.set(nonce, 0);
|
|
362
|
+
combined.set(encrypted, nonce.length);
|
|
363
|
+
return b64Encode(combined);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
if (err instanceof EncryptionError) throw err;
|
|
366
|
+
throw new EncryptionError(err instanceof Error ? err.message : String(err));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function decryptPayload(ciphertextB64, recipientSigningKey, senderVerifyKey) {
|
|
370
|
+
try {
|
|
371
|
+
const combined = b64Decode(ciphertextB64);
|
|
372
|
+
const nonce = combined.slice(0, sodium.crypto_box_NONCEBYTES);
|
|
373
|
+
const ciphertext = combined.slice(sodium.crypto_box_NONCEBYTES);
|
|
374
|
+
const recipientCurve = sodium.crypto_sign_ed25519_sk_to_curve25519(recipientSigningKey);
|
|
375
|
+
const senderCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(senderVerifyKey);
|
|
376
|
+
return sodium.crypto_box_open_easy(ciphertext, nonce, senderCurve, recipientCurve);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
if (err instanceof DecryptionError) throw err;
|
|
379
|
+
throw new DecryptionError(err instanceof Error ? err.message : String(err));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function encryptPayloadAnonymous(plaintext, recipientVerifyKey) {
|
|
383
|
+
const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(recipientVerifyKey);
|
|
384
|
+
const ciphertext = sodium.crypto_box_seal(plaintext, recipientCurve);
|
|
385
|
+
return b64Encode(ciphertext);
|
|
386
|
+
}
|
|
387
|
+
function decryptPayloadAnonymous(ciphertextB64, recipientSigningKey) {
|
|
388
|
+
try {
|
|
389
|
+
const ciphertext = b64Decode(ciphertextB64);
|
|
390
|
+
const recipientCurveSk = sodium.crypto_sign_ed25519_sk_to_curve25519(recipientSigningKey);
|
|
391
|
+
const recipientVerifyKey = recipientSigningKey.slice(32);
|
|
392
|
+
const recipientCurvePk = sodium.crypto_sign_ed25519_pk_to_curve25519(recipientVerifyKey);
|
|
393
|
+
return sodium.crypto_box_seal_open(ciphertext, recipientCurvePk, recipientCurveSk);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
if (err instanceof DecryptionError) throw err;
|
|
396
|
+
throw new DecryptionError(err instanceof Error ? err.message : String(err));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// src/protocol/envelope.ts
|
|
401
|
+
import { randomUUID } from "crypto";
|
|
402
|
+
var REQUIRED_WIRE_FIELDS = /* @__PURE__ */ new Set([
|
|
403
|
+
"uam_version",
|
|
404
|
+
"message_id",
|
|
405
|
+
"from",
|
|
406
|
+
"to",
|
|
407
|
+
"timestamp",
|
|
408
|
+
"type",
|
|
409
|
+
"nonce",
|
|
410
|
+
"payload",
|
|
411
|
+
"signature"
|
|
412
|
+
]);
|
|
413
|
+
function buildSignableDict(envelope) {
|
|
414
|
+
const d = {
|
|
415
|
+
uam_version: envelope.uamVersion,
|
|
416
|
+
message_id: envelope.messageId,
|
|
417
|
+
from: envelope.fromAddress,
|
|
418
|
+
to: envelope.toAddress,
|
|
419
|
+
timestamp: envelope.timestamp,
|
|
420
|
+
type: envelope.type,
|
|
421
|
+
nonce: envelope.nonce,
|
|
422
|
+
payload: envelope.payload
|
|
423
|
+
};
|
|
424
|
+
if (envelope.threadId !== void 0) d["thread_id"] = envelope.threadId;
|
|
425
|
+
if (envelope.replyTo !== void 0) d["reply_to"] = envelope.replyTo;
|
|
426
|
+
if (envelope.expires !== void 0) d["expires"] = envelope.expires;
|
|
427
|
+
if (envelope.mediaType !== void 0) d["media_type"] = envelope.mediaType;
|
|
428
|
+
if (envelope.metadata !== void 0) d["metadata"] = envelope.metadata;
|
|
429
|
+
return d;
|
|
430
|
+
}
|
|
431
|
+
function toWireDict(envelope) {
|
|
432
|
+
const d = buildSignableDict(envelope);
|
|
433
|
+
d["signature"] = envelope.signature;
|
|
434
|
+
if (envelope.attachments !== void 0) {
|
|
435
|
+
d["attachments"] = envelope.attachments;
|
|
436
|
+
}
|
|
437
|
+
return d;
|
|
438
|
+
}
|
|
439
|
+
function fromWireDict(d) {
|
|
440
|
+
const keys = new Set(Object.keys(d));
|
|
441
|
+
const missing = [];
|
|
442
|
+
for (const field of REQUIRED_WIRE_FIELDS) {
|
|
443
|
+
if (!keys.has(field)) missing.push(field);
|
|
444
|
+
}
|
|
445
|
+
if (missing.length > 0) {
|
|
446
|
+
missing.sort();
|
|
447
|
+
throw new InvalidEnvelopeError(
|
|
448
|
+
`Missing required fields: ${JSON.stringify(missing)}`
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
return Object.freeze({
|
|
452
|
+
uamVersion: d["uam_version"],
|
|
453
|
+
messageId: d["message_id"],
|
|
454
|
+
fromAddress: d["from"],
|
|
455
|
+
toAddress: d["to"],
|
|
456
|
+
timestamp: d["timestamp"],
|
|
457
|
+
type: d["type"],
|
|
458
|
+
nonce: d["nonce"],
|
|
459
|
+
payload: d["payload"],
|
|
460
|
+
signature: d["signature"],
|
|
461
|
+
threadId: d["thread_id"],
|
|
462
|
+
replyTo: d["reply_to"],
|
|
463
|
+
expires: d["expires"],
|
|
464
|
+
mediaType: d["media_type"],
|
|
465
|
+
metadata: d["metadata"],
|
|
466
|
+
attachments: d["attachments"]
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
function validateEnvelopeSize(envelope) {
|
|
470
|
+
const wire = toWireDict(envelope);
|
|
471
|
+
const json = JSON.stringify(wire);
|
|
472
|
+
const size = new TextEncoder().encode(json).length;
|
|
473
|
+
if (size > MAX_ENVELOPE_SIZE) {
|
|
474
|
+
throw new EnvelopeTooLargeError(
|
|
475
|
+
`Envelope size ${size} bytes exceeds maximum ${MAX_ENVELOPE_SIZE} bytes`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
async function createEnvelope(fromAddress, toAddress, messageType, payloadPlaintext, signingKey, recipientVerifyKey, options) {
|
|
480
|
+
const typeValue = typeof messageType === "string" ? messageType : messageType;
|
|
481
|
+
parseAddress(fromAddress);
|
|
482
|
+
parseAddress(toAddress);
|
|
483
|
+
const messageId = randomUUID();
|
|
484
|
+
const nonce = generateNonce();
|
|
485
|
+
const timestamp = utcTimestamp();
|
|
486
|
+
let encryptedPayload;
|
|
487
|
+
if (typeValue === "handshake.request" /* HANDSHAKE_REQUEST */) {
|
|
488
|
+
encryptedPayload = encryptPayloadAnonymous(payloadPlaintext, recipientVerifyKey);
|
|
489
|
+
} else {
|
|
490
|
+
encryptedPayload = encryptPayload(
|
|
491
|
+
payloadPlaintext,
|
|
492
|
+
signingKey,
|
|
493
|
+
recipientVerifyKey
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
const tempEnvelope = {
|
|
497
|
+
uamVersion: UAM_VERSION,
|
|
498
|
+
messageId,
|
|
499
|
+
fromAddress,
|
|
500
|
+
toAddress,
|
|
501
|
+
timestamp,
|
|
502
|
+
type: typeValue,
|
|
503
|
+
nonce,
|
|
504
|
+
payload: encryptedPayload,
|
|
505
|
+
signature: "",
|
|
506
|
+
threadId: options?.threadId,
|
|
507
|
+
replyTo: options?.replyTo,
|
|
508
|
+
expires: options?.expires,
|
|
509
|
+
mediaType: options?.mediaType,
|
|
510
|
+
metadata: options?.metadata,
|
|
511
|
+
attachments: options?.attachments
|
|
512
|
+
};
|
|
513
|
+
const signable = buildSignableDict(tempEnvelope);
|
|
514
|
+
const signature = signMessage(canonicalize(signable), signingKey);
|
|
515
|
+
const finalEnvelope = Object.freeze({
|
|
516
|
+
uamVersion: UAM_VERSION,
|
|
517
|
+
messageId,
|
|
518
|
+
fromAddress,
|
|
519
|
+
toAddress,
|
|
520
|
+
timestamp,
|
|
521
|
+
type: typeValue,
|
|
522
|
+
nonce,
|
|
523
|
+
payload: encryptedPayload,
|
|
524
|
+
signature,
|
|
525
|
+
threadId: options?.threadId,
|
|
526
|
+
replyTo: options?.replyTo,
|
|
527
|
+
expires: options?.expires,
|
|
528
|
+
mediaType: options?.mediaType,
|
|
529
|
+
metadata: options?.metadata,
|
|
530
|
+
attachments: options?.attachments
|
|
531
|
+
});
|
|
532
|
+
validateEnvelopeSize(finalEnvelope);
|
|
533
|
+
return finalEnvelope;
|
|
534
|
+
}
|
|
535
|
+
function verifyEnvelope(envelope, senderVerifyKey) {
|
|
536
|
+
const signable = buildSignableDict(envelope);
|
|
537
|
+
verifySignature(canonicalize(signable), envelope.signature, senderVerifyKey);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/protocol/contact.ts
|
|
541
|
+
function buildSignableDict2(card) {
|
|
542
|
+
const d = {
|
|
543
|
+
version: card.version,
|
|
544
|
+
address: card.address,
|
|
545
|
+
display_name: card.displayName,
|
|
546
|
+
relay: card.relay,
|
|
547
|
+
public_key: card.publicKey
|
|
548
|
+
};
|
|
549
|
+
if (card.description !== void 0) d["description"] = card.description;
|
|
550
|
+
if (card.system !== void 0) d["system"] = card.system;
|
|
551
|
+
if (card.connectionEndpoint !== void 0) {
|
|
552
|
+
d["connection_endpoint"] = card.connectionEndpoint;
|
|
553
|
+
}
|
|
554
|
+
if (card.verifiedDomain !== void 0) {
|
|
555
|
+
d["verified_domain"] = card.verifiedDomain;
|
|
556
|
+
}
|
|
557
|
+
return d;
|
|
558
|
+
}
|
|
559
|
+
function contactCardToDict(card) {
|
|
560
|
+
const d = buildSignableDict2(card);
|
|
561
|
+
d["signature"] = card.signature;
|
|
562
|
+
if (card.payloadFormats !== void 0) {
|
|
563
|
+
d["payload_formats"] = card.payloadFormats;
|
|
564
|
+
}
|
|
565
|
+
if (card.fingerprint !== void 0) {
|
|
566
|
+
d["fingerprint"] = card.fingerprint;
|
|
567
|
+
}
|
|
568
|
+
if (card.relays !== void 0) {
|
|
569
|
+
d["relays"] = card.relays;
|
|
570
|
+
}
|
|
571
|
+
return d;
|
|
572
|
+
}
|
|
573
|
+
function contactCardFromDict(d, options) {
|
|
574
|
+
const verify = options?.verify ?? true;
|
|
575
|
+
const required = /* @__PURE__ */ new Set([
|
|
576
|
+
"version",
|
|
577
|
+
"address",
|
|
578
|
+
"display_name",
|
|
579
|
+
"relay",
|
|
580
|
+
"public_key",
|
|
581
|
+
"signature"
|
|
582
|
+
]);
|
|
583
|
+
const keys = new Set(Object.keys(d));
|
|
584
|
+
const missing = [];
|
|
585
|
+
for (const field of required) {
|
|
586
|
+
if (!keys.has(field)) missing.push(field);
|
|
587
|
+
}
|
|
588
|
+
if (missing.length > 0) {
|
|
589
|
+
missing.sort();
|
|
590
|
+
throw new InvalidContactCardError(
|
|
591
|
+
`Missing required fields: ${JSON.stringify(missing)}`
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
const card = Object.freeze({
|
|
595
|
+
version: d["version"],
|
|
596
|
+
address: d["address"],
|
|
597
|
+
displayName: d["display_name"],
|
|
598
|
+
description: d["description"],
|
|
599
|
+
system: d["system"],
|
|
600
|
+
connectionEndpoint: d["connection_endpoint"],
|
|
601
|
+
relay: d["relay"],
|
|
602
|
+
publicKey: d["public_key"],
|
|
603
|
+
signature: d["signature"],
|
|
604
|
+
verifiedDomain: d["verified_domain"],
|
|
605
|
+
payloadFormats: d["payload_formats"],
|
|
606
|
+
fingerprint: d["fingerprint"],
|
|
607
|
+
relays: d["relays"]
|
|
608
|
+
});
|
|
609
|
+
if (verify) {
|
|
610
|
+
verifyContactCard(card);
|
|
611
|
+
}
|
|
612
|
+
return card;
|
|
613
|
+
}
|
|
614
|
+
async function createContactCard(address, displayName, relay, signingKey, options) {
|
|
615
|
+
parseAddress(address);
|
|
616
|
+
const verifyKey = signingKey.slice(32);
|
|
617
|
+
const pubKeyB64 = serializeVerifyKey(verifyKey);
|
|
618
|
+
const fp = publicKeyFingerprint(verifyKey);
|
|
619
|
+
const payloadFormats = options?.payloadFormats ?? [
|
|
620
|
+
"text/plain",
|
|
621
|
+
"text/markdown"
|
|
622
|
+
];
|
|
623
|
+
const tempCard = {
|
|
624
|
+
version: UAM_VERSION,
|
|
625
|
+
address,
|
|
626
|
+
displayName,
|
|
627
|
+
description: options?.description,
|
|
628
|
+
system: options?.system,
|
|
629
|
+
connectionEndpoint: options?.connectionEndpoint,
|
|
630
|
+
relay,
|
|
631
|
+
publicKey: pubKeyB64,
|
|
632
|
+
signature: "",
|
|
633
|
+
verifiedDomain: options?.verifiedDomain,
|
|
634
|
+
payloadFormats,
|
|
635
|
+
fingerprint: fp,
|
|
636
|
+
relays: options?.relays
|
|
637
|
+
};
|
|
638
|
+
const signable = buildSignableDict2(tempCard);
|
|
639
|
+
const signature = signMessage(canonicalize(signable), signingKey);
|
|
640
|
+
return Object.freeze({
|
|
641
|
+
version: UAM_VERSION,
|
|
642
|
+
address,
|
|
643
|
+
displayName,
|
|
644
|
+
description: options?.description,
|
|
645
|
+
system: options?.system,
|
|
646
|
+
connectionEndpoint: options?.connectionEndpoint,
|
|
647
|
+
relay,
|
|
648
|
+
publicKey: pubKeyB64,
|
|
649
|
+
signature,
|
|
650
|
+
verifiedDomain: options?.verifiedDomain,
|
|
651
|
+
payloadFormats,
|
|
652
|
+
fingerprint: fp,
|
|
653
|
+
relays: options?.relays
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
function verifyContactCard(card) {
|
|
657
|
+
try {
|
|
658
|
+
parseAddress(card.address);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
throw new InvalidContactCardError(
|
|
661
|
+
`Invalid address in contact card: ${err instanceof Error ? err.message : String(err)}`
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
const vk = deserializeVerifyKey(card.publicKey);
|
|
665
|
+
const signable = buildSignableDict2(card);
|
|
666
|
+
verifySignature(canonicalize(signable), card.signature, vk);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/version.ts
|
|
670
|
+
var SDK_VERSION = "0.2.1";
|
|
671
|
+
|
|
672
|
+
// src/sdk/config.ts
|
|
673
|
+
import { homedir } from "os";
|
|
674
|
+
import { join } from "path";
|
|
675
|
+
var DEFAULT_RELAY_URL = "https://relay.youam.network";
|
|
676
|
+
function trimRelayUrl(url) {
|
|
677
|
+
return url.replace(/\/+$/, "");
|
|
678
|
+
}
|
|
679
|
+
var VALID_POLICIES = /* @__PURE__ */ new Set([
|
|
680
|
+
"auto-accept",
|
|
681
|
+
"approval-required",
|
|
682
|
+
"allowlist-only",
|
|
683
|
+
"require_verify"
|
|
684
|
+
]);
|
|
685
|
+
var SDKConfig = class {
|
|
686
|
+
name;
|
|
687
|
+
relayUrl;
|
|
688
|
+
relayWsUrl;
|
|
689
|
+
keyDir;
|
|
690
|
+
dataDir;
|
|
691
|
+
displayName;
|
|
692
|
+
transportType;
|
|
693
|
+
trustPolicy;
|
|
694
|
+
relayDomain;
|
|
695
|
+
/** The domain the caller chose, if any; null when only derived. */
|
|
696
|
+
explicitRelayDomain;
|
|
697
|
+
constructor(options) {
|
|
698
|
+
this.name = options.name;
|
|
699
|
+
this.relayUrl = trimRelayUrl(
|
|
700
|
+
options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL
|
|
701
|
+
);
|
|
702
|
+
if (options.relayWsUrl) {
|
|
703
|
+
this.relayWsUrl = options.relayWsUrl;
|
|
704
|
+
} else {
|
|
705
|
+
let wsUrl = this.relayUrl.replace("https://", "wss://").replace("http://", "ws://");
|
|
706
|
+
if (!wsUrl.endsWith("/ws")) {
|
|
707
|
+
wsUrl = wsUrl.replace(/\/+$/, "") + "/ws";
|
|
708
|
+
}
|
|
709
|
+
this.relayWsUrl = wsUrl;
|
|
710
|
+
}
|
|
711
|
+
const envDomain = process.env["UAM_RELAY_DOMAIN"];
|
|
712
|
+
this.explicitRelayDomain = envDomain || options.relayDomain || null;
|
|
713
|
+
if (envDomain) {
|
|
714
|
+
this.relayDomain = envDomain;
|
|
715
|
+
} else if (options.relayDomain) {
|
|
716
|
+
this.relayDomain = options.relayDomain;
|
|
717
|
+
} else {
|
|
718
|
+
try {
|
|
719
|
+
const parsed = new URL(this.relayUrl);
|
|
720
|
+
this.relayDomain = parsed.hostname || "localhost";
|
|
721
|
+
} catch {
|
|
722
|
+
const afterScheme = this.relayUrl.split("://").pop() || "";
|
|
723
|
+
this.relayDomain = afterScheme.split("/")[0] || "localhost";
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const uamHome = process.env["UAM_HOME"];
|
|
727
|
+
const defaultHome = uamHome || join(homedir(), ".uam");
|
|
728
|
+
this.keyDir = options.keyDir ?? join(defaultHome, "keys");
|
|
729
|
+
this.dataDir = options.dataDir ?? defaultHome;
|
|
730
|
+
this.displayName = options.displayName ?? this.name;
|
|
731
|
+
this.transportType = options.transportType ?? "websocket";
|
|
732
|
+
let policy = options.trustPolicy ?? "auto-accept";
|
|
733
|
+
const envPolicy = process.env["UAM_TRUST_POLICY"];
|
|
734
|
+
if (envPolicy) {
|
|
735
|
+
policy = envPolicy;
|
|
736
|
+
}
|
|
737
|
+
if (!VALID_POLICIES.has(policy)) {
|
|
738
|
+
throw new Error(
|
|
739
|
+
`Invalid trust_policy '${policy}'. Must be one of: ${JSON.stringify([...VALID_POLICIES].sort())}`
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
this.trustPolicy = policy;
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
// src/sdk/freshness.ts
|
|
747
|
+
var RELAY_META_FEATURE = "relay_meta";
|
|
748
|
+
var STORED_MAIL_MAX_AGE = 7 * 86400;
|
|
749
|
+
var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
750
|
+
function relayVouches(raw, signedAt, now, relayDomain) {
|
|
751
|
+
const meta = raw["relay_meta"];
|
|
752
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
const stamp = meta;
|
|
756
|
+
if (stamp["v"] !== 1 || stamp["relay"] !== relayDomain) return false;
|
|
757
|
+
if (stamp["stored"] !== true) return false;
|
|
758
|
+
const received = stamp["received_at"];
|
|
759
|
+
if (typeof received !== "string" || !ISO_TIME.test(received)) return false;
|
|
760
|
+
const receivedAt = Date.parse(received);
|
|
761
|
+
if (Number.isNaN(receivedAt)) return false;
|
|
762
|
+
const window = MAX_ENVELOPE_AGE * 1e3;
|
|
763
|
+
const age = now - signedAt;
|
|
764
|
+
return Math.abs(receivedAt - signedAt) <= window && age >= 0 && age <= STORED_MAIL_MAX_AGE * 1e3 + window;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// src/sdk/key-manager.ts
|
|
768
|
+
import {
|
|
769
|
+
chmodSync,
|
|
770
|
+
mkdirSync,
|
|
771
|
+
readFileSync,
|
|
772
|
+
statSync,
|
|
773
|
+
writeFileSync
|
|
774
|
+
} from "fs";
|
|
775
|
+
import { existsSync } from "fs";
|
|
776
|
+
import { join as join2 } from "path";
|
|
777
|
+
import { homedir as homedir2 } from "os";
|
|
778
|
+
import { platform } from "os";
|
|
779
|
+
var DEFAULT_KEY_DIR = join2(homedir2(), ".uam", "keys");
|
|
780
|
+
var KeyManager = class {
|
|
781
|
+
_keyDir;
|
|
782
|
+
_keypair = null;
|
|
783
|
+
constructor(keyDir) {
|
|
784
|
+
this._keyDir = keyDir ?? DEFAULT_KEY_DIR;
|
|
785
|
+
}
|
|
786
|
+
/** The 64-byte Ed25519 signing key. Throws if not loaded. */
|
|
787
|
+
get signingKey() {
|
|
788
|
+
if (this._keypair === null) {
|
|
789
|
+
throw new Error("No keypair loaded. Call loadOrGenerate() first.");
|
|
790
|
+
}
|
|
791
|
+
return this._keypair.signingKey;
|
|
792
|
+
}
|
|
793
|
+
/** The 32-byte Ed25519 verify (public) key. Throws if not loaded. */
|
|
794
|
+
get verifyKey() {
|
|
795
|
+
if (this._keypair === null) {
|
|
796
|
+
throw new Error("No keypair loaded. Call loadOrGenerate() first.");
|
|
797
|
+
}
|
|
798
|
+
return this._keypair.verifyKey;
|
|
799
|
+
}
|
|
800
|
+
/** The 32-byte seed. Throws if not loaded. */
|
|
801
|
+
get seed() {
|
|
802
|
+
if (this._keypair === null) {
|
|
803
|
+
throw new Error("No keypair loaded. Call loadOrGenerate() first.");
|
|
804
|
+
}
|
|
805
|
+
return this._keypair.seed;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Load existing keypair or generate a new one.
|
|
809
|
+
*
|
|
810
|
+
* Checks UAM_SIGNING_KEY env var first (base64-encoded Ed25519 seed).
|
|
811
|
+
* Falls back to file-based storage at {keyDir}/{name}.key.
|
|
812
|
+
* Generates a new keypair if neither source exists.
|
|
813
|
+
*/
|
|
814
|
+
loadOrGenerate(name) {
|
|
815
|
+
const envKey = process.env["UAM_SIGNING_KEY"];
|
|
816
|
+
if (envKey) {
|
|
817
|
+
this._keypair = deserializeSigningKey(envKey.trim());
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
mkdirSync(this._keyDir, { recursive: true });
|
|
821
|
+
const keyPath = join2(this._keyDir, `${name}.key`);
|
|
822
|
+
const pubPath = join2(this._keyDir, `${name}.pub`);
|
|
823
|
+
if (existsSync(keyPath)) {
|
|
824
|
+
this._checkPermissions(keyPath);
|
|
825
|
+
const seedB64 = readFileSync(keyPath, "utf-8").trim();
|
|
826
|
+
this._keypair = deserializeSigningKey(seedB64);
|
|
827
|
+
} else {
|
|
828
|
+
this._keypair = generateKeypair();
|
|
829
|
+
writeFileSync(keyPath, serializeSigningKey(this._keypair.seed));
|
|
830
|
+
writeFileSync(pubPath, serializeVerifyKey(this._keypair.verifyKey));
|
|
831
|
+
this._setPermissions(keyPath);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Store the relay token alongside the keypair.
|
|
836
|
+
*/
|
|
837
|
+
saveToken(name, token) {
|
|
838
|
+
mkdirSync(this._keyDir, { recursive: true });
|
|
839
|
+
const tokenPath = join2(this._keyDir, `${name}.token`);
|
|
840
|
+
writeFileSync(tokenPath, token);
|
|
841
|
+
this._setPermissions(tokenPath);
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Store the address `relayUrl` gave this agent.
|
|
845
|
+
*/
|
|
846
|
+
saveAddress(name, address, relayUrl) {
|
|
847
|
+
mkdirSync(this._keyDir, { recursive: true });
|
|
848
|
+
writeFileSync(
|
|
849
|
+
join2(this._keyDir, `${name}.address`),
|
|
850
|
+
JSON.stringify({ address, relay_url: relayUrl })
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Load the address saved for `relayUrl`.
|
|
855
|
+
*
|
|
856
|
+
* Returns null if there is none: the file is missing, unreadable or
|
|
857
|
+
* malformed, or it belongs to another relay.
|
|
858
|
+
*/
|
|
859
|
+
loadAddress(name, relayUrl) {
|
|
860
|
+
try {
|
|
861
|
+
const path = join2(this._keyDir, `${name}.address`);
|
|
862
|
+
const saved = JSON.parse(readFileSync(path, "utf-8"));
|
|
863
|
+
const address = saved?.address;
|
|
864
|
+
const savedRelay = saved?.relay_url;
|
|
865
|
+
if (typeof address !== "string" || typeof savedRelay !== "string") {
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
if (trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl)) return null;
|
|
869
|
+
parseAddress(address);
|
|
870
|
+
return address;
|
|
871
|
+
} catch {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Load a previously saved token, or return null.
|
|
877
|
+
*
|
|
878
|
+
* Checks UAM_TOKEN env var first, then file-based storage,
|
|
879
|
+
* then legacy .api_key files for backward compatibility. With `relayUrl`,
|
|
880
|
+
* a saved token that belongs to another relay counts as none, so it is
|
|
881
|
+
* only offered to the relay that issued it.
|
|
882
|
+
*/
|
|
883
|
+
loadToken(name, relayUrl) {
|
|
884
|
+
const envToken = process.env["UAM_TOKEN"];
|
|
885
|
+
if (envToken) {
|
|
886
|
+
return envToken.trim();
|
|
887
|
+
}
|
|
888
|
+
if (relayUrl !== void 0 && this._savedForAnotherRelay(name, relayUrl)) {
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
const tokenPath = join2(this._keyDir, `${name}.token`);
|
|
892
|
+
if (existsSync(tokenPath)) {
|
|
893
|
+
return readFileSync(tokenPath, "utf-8").trim();
|
|
894
|
+
}
|
|
895
|
+
const legacyPath = join2(this._keyDir, `${name}.api_key`);
|
|
896
|
+
if (existsSync(legacyPath)) {
|
|
897
|
+
return readFileSync(legacyPath, "utf-8").trim();
|
|
898
|
+
}
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* True if the saved address says this agent's files are another relay's.
|
|
903
|
+
* A missing or damaged file says nothing.
|
|
904
|
+
*/
|
|
905
|
+
_savedForAnotherRelay(name, relayUrl) {
|
|
906
|
+
try {
|
|
907
|
+
const path = join2(this._keyDir, `${name}.address`);
|
|
908
|
+
const saved = JSON.parse(readFileSync(path, "utf-8"));
|
|
909
|
+
const savedRelay = saved?.relay_url;
|
|
910
|
+
return typeof savedRelay === "string" && trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl);
|
|
911
|
+
} catch {
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/** Set file permissions to 0o600 (owner read/write only). */
|
|
916
|
+
_setPermissions(path) {
|
|
917
|
+
if (platform() !== "win32") {
|
|
918
|
+
chmodSync(path, 384);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/** Warn if key file permissions are too permissive. */
|
|
922
|
+
_checkPermissions(path) {
|
|
923
|
+
if (platform() === "win32") {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
try {
|
|
927
|
+
const mode = statSync(path).mode & 511;
|
|
928
|
+
if (mode !== 384) {
|
|
929
|
+
console.warn(
|
|
930
|
+
`Key file ${path} has permissions ${mode.toString(8)} (expected 600). Run: chmod 600 ${path}`
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
} catch {
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
// src/sdk/contact-book.ts
|
|
939
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
940
|
+
import { join as join3, dirname } from "path";
|
|
941
|
+
import Database from "better-sqlite3";
|
|
942
|
+
|
|
943
|
+
// src/sdk/log.ts
|
|
944
|
+
var log = {
|
|
945
|
+
warn(message) {
|
|
946
|
+
console.warn(`[uam] ${message}`);
|
|
947
|
+
},
|
|
948
|
+
info(message) {
|
|
949
|
+
if (process.env["UAM_DEBUG"]) {
|
|
950
|
+
console.info(`[uam] ${message}`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
// src/sdk/contact-book.ts
|
|
956
|
+
var ContactBook = class {
|
|
957
|
+
_dbPath;
|
|
958
|
+
_db = null;
|
|
959
|
+
_knownAddresses = /* @__PURE__ */ new Set();
|
|
960
|
+
_blockedExact = /* @__PURE__ */ new Set();
|
|
961
|
+
_blockedDomains = /* @__PURE__ */ new Set();
|
|
962
|
+
constructor(dataDir) {
|
|
963
|
+
this._dbPath = join3(dataDir, "contacts", "contacts.db");
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Open the database, create tables, run migrations, load caches.
|
|
967
|
+
*/
|
|
968
|
+
open() {
|
|
969
|
+
mkdirSync2(dirname(this._dbPath), { recursive: true });
|
|
970
|
+
this._db = new Database(this._dbPath);
|
|
971
|
+
this._db.pragma("journal_mode = WAL");
|
|
972
|
+
this._db.exec(`
|
|
973
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
974
|
+
address TEXT PRIMARY KEY,
|
|
975
|
+
public_key TEXT NOT NULL,
|
|
976
|
+
display_name TEXT,
|
|
977
|
+
trust_state TEXT NOT NULL DEFAULT 'unknown',
|
|
978
|
+
trust_source TEXT DEFAULT 'legacy-unknown',
|
|
979
|
+
relay TEXT,
|
|
980
|
+
relays_json TEXT,
|
|
981
|
+
pinned_at TEXT,
|
|
982
|
+
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
|
983
|
+
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
|
|
984
|
+
);
|
|
985
|
+
|
|
986
|
+
CREATE TABLE IF NOT EXISTS pending_handshakes (
|
|
987
|
+
address TEXT PRIMARY KEY,
|
|
988
|
+
contact_card TEXT NOT NULL,
|
|
989
|
+
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
CREATE TABLE IF NOT EXISTS blocked_patterns (
|
|
993
|
+
pattern TEXT PRIMARY KEY,
|
|
994
|
+
blocked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
995
|
+
);
|
|
996
|
+
`);
|
|
997
|
+
this._migrate();
|
|
998
|
+
const rows = this._db.prepare("SELECT address FROM contacts").all();
|
|
999
|
+
this._knownAddresses = new Set(rows.map((r) => r.address));
|
|
1000
|
+
this._blockedExact.clear();
|
|
1001
|
+
this._blockedDomains.clear();
|
|
1002
|
+
const blocked = this._db.prepare("SELECT pattern FROM blocked_patterns").all();
|
|
1003
|
+
for (const row of blocked) {
|
|
1004
|
+
this._cacheBlockPattern(row.pattern);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Run schema migrations using PRAGMA user_version.
|
|
1009
|
+
*/
|
|
1010
|
+
_migrate() {
|
|
1011
|
+
if (!this._db) return;
|
|
1012
|
+
const versionRow = this._db.prepare("PRAGMA user_version").get();
|
|
1013
|
+
const version = versionRow.user_version;
|
|
1014
|
+
if (version < 2) {
|
|
1015
|
+
this._db.exec("PRAGMA user_version = 3");
|
|
1016
|
+
} else if (version < 3) {
|
|
1017
|
+
this._db.exec(
|
|
1018
|
+
"ALTER TABLE contacts ADD COLUMN pinned_at TEXT"
|
|
1019
|
+
);
|
|
1020
|
+
this._db.exec("PRAGMA user_version = 3");
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Close the database connection.
|
|
1025
|
+
*/
|
|
1026
|
+
close() {
|
|
1027
|
+
if (this._db !== null) {
|
|
1028
|
+
this._db.close();
|
|
1029
|
+
this._db = null;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Check if an address is in the contact book (in-memory, no I/O).
|
|
1034
|
+
*/
|
|
1035
|
+
isKnown(address) {
|
|
1036
|
+
return this._knownAddresses.has(address);
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Check if an address matches any blocked pattern (O(1) set lookup).
|
|
1040
|
+
*/
|
|
1041
|
+
isBlocked(address) {
|
|
1042
|
+
if (this._blockedExact.has(address)) {
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
if (address.includes("::")) {
|
|
1046
|
+
const domain = address.split("::")[1];
|
|
1047
|
+
return this._blockedDomains.has(domain);
|
|
1048
|
+
}
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Look up the public key for a known contact.
|
|
1053
|
+
*/
|
|
1054
|
+
getPublicKey(address) {
|
|
1055
|
+
if (this._db === null) return null;
|
|
1056
|
+
const row = this._db.prepare("SELECT public_key FROM contacts WHERE address = ?").get(address);
|
|
1057
|
+
return row?.public_key ?? null;
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Return the relay URLs for a known contact (CARD-04).
|
|
1061
|
+
*/
|
|
1062
|
+
getRelayUrls(address) {
|
|
1063
|
+
if (this._db === null) return null;
|
|
1064
|
+
const row = this._db.prepare("SELECT relay, relays_json FROM contacts WHERE address = ?").get(address);
|
|
1065
|
+
if (!row) return null;
|
|
1066
|
+
if (row.relays_json !== null) {
|
|
1067
|
+
return JSON.parse(row.relays_json);
|
|
1068
|
+
}
|
|
1069
|
+
if (row.relay !== null) {
|
|
1070
|
+
return [row.relay];
|
|
1071
|
+
}
|
|
1072
|
+
return null;
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Add or update a contact (upsert).
|
|
1076
|
+
*
|
|
1077
|
+
* Refuses to replace the key of a contact whose trust_state is "pinned"
|
|
1078
|
+
* or "verified" with a different key, unless `force: true` is passed (for
|
|
1079
|
+
* a deliberate key rotation, which is logged). Matches the Python SDK's
|
|
1080
|
+
* ContactBook.add_contact.
|
|
1081
|
+
*
|
|
1082
|
+
* @throws {KeyPinningError} If a pinned/verified contact's key would be
|
|
1083
|
+
* replaced by a different key without `force`.
|
|
1084
|
+
*/
|
|
1085
|
+
addContact(address, publicKey, options) {
|
|
1086
|
+
if (this._db === null) {
|
|
1087
|
+
throw new Error("ContactBook not open. Call open() first.");
|
|
1088
|
+
}
|
|
1089
|
+
const existing = this._db.prepare("SELECT public_key, trust_state FROM contacts WHERE address = ?").get(address);
|
|
1090
|
+
if (existing !== void 0 && (existing.trust_state === "pinned" || existing.trust_state === "verified") && existing.public_key !== publicKey) {
|
|
1091
|
+
if (!options?.force) {
|
|
1092
|
+
throw new KeyPinningError(
|
|
1093
|
+
`Refusing to overwrite ${existing.trust_state} contact ${JSON.stringify(address)}: stored public key does not match the new key. Pass force: true to rotate.`
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
log.warn(
|
|
1097
|
+
`Force-replacing the key of ${existing.trust_state} contact ${address} (key rotation)`
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
const displayName = options?.displayName ?? null;
|
|
1101
|
+
const trustState = options?.trustState ?? "trusted";
|
|
1102
|
+
const trustSource = options?.trustSource ?? null;
|
|
1103
|
+
const relay = options?.relay ?? null;
|
|
1104
|
+
const relaysJson = options?.relays !== void 0 && options?.relays !== null ? JSON.stringify(options.relays) : null;
|
|
1105
|
+
this._db.prepare(
|
|
1106
|
+
`INSERT INTO contacts (address, public_key, display_name, trust_state, trust_source, relay, relays_json)
|
|
1107
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1108
|
+
ON CONFLICT(address) DO UPDATE SET
|
|
1109
|
+
public_key = excluded.public_key,
|
|
1110
|
+
display_name = excluded.display_name,
|
|
1111
|
+
trust_state = excluded.trust_state,
|
|
1112
|
+
trust_source = COALESCE(excluded.trust_source, contacts.trust_source),
|
|
1113
|
+
relay = COALESCE(excluded.relay, contacts.relay),
|
|
1114
|
+
relays_json = COALESCE(excluded.relays_json, contacts.relays_json),
|
|
1115
|
+
last_seen = datetime('now')`
|
|
1116
|
+
).run(address, publicKey, displayName, trustState, trustSource, relay, relaysJson);
|
|
1117
|
+
this._knownAddresses.add(address);
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Return all contacts sorted by last_seen DESC.
|
|
1121
|
+
*/
|
|
1122
|
+
listContacts() {
|
|
1123
|
+
if (this._db === null) return [];
|
|
1124
|
+
const rows = this._db.prepare(
|
|
1125
|
+
"SELECT address, display_name, trust_state, first_seen, last_seen FROM contacts ORDER BY last_seen DESC"
|
|
1126
|
+
).all();
|
|
1127
|
+
return rows.map((r) => ({
|
|
1128
|
+
address: r.address,
|
|
1129
|
+
displayName: r.display_name,
|
|
1130
|
+
trustState: r.trust_state,
|
|
1131
|
+
firstSeen: r.first_seen,
|
|
1132
|
+
lastSeen: r.last_seen
|
|
1133
|
+
}));
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Store a pending handshake request.
|
|
1137
|
+
*/
|
|
1138
|
+
addPending(address, contactCardJson) {
|
|
1139
|
+
if (this._db === null) {
|
|
1140
|
+
throw new Error("ContactBook not open. Call open() first.");
|
|
1141
|
+
}
|
|
1142
|
+
this._db.prepare(
|
|
1143
|
+
`INSERT OR REPLACE INTO pending_handshakes (address, contact_card) VALUES (?, ?)`
|
|
1144
|
+
).run(address, contactCardJson);
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Retrieve all pending handshake requests.
|
|
1148
|
+
*/
|
|
1149
|
+
getPending() {
|
|
1150
|
+
if (this._db === null) return [];
|
|
1151
|
+
const rows = this._db.prepare(
|
|
1152
|
+
"SELECT address, contact_card, received_at FROM pending_handshakes"
|
|
1153
|
+
).all();
|
|
1154
|
+
return rows.map((r) => ({
|
|
1155
|
+
address: r.address,
|
|
1156
|
+
contactCard: r.contact_card,
|
|
1157
|
+
receivedAt: r.received_at
|
|
1158
|
+
}));
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Remove a pending handshake request.
|
|
1162
|
+
*/
|
|
1163
|
+
removePending(address) {
|
|
1164
|
+
if (this._db === null) return;
|
|
1165
|
+
this._db.prepare("DELETE FROM pending_handshakes WHERE address = ?").run(address);
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Return pending handshakes older than N days.
|
|
1169
|
+
*/
|
|
1170
|
+
getExpiredPending(days = 7) {
|
|
1171
|
+
if (this._db === null) return [];
|
|
1172
|
+
const rows = this._db.prepare(
|
|
1173
|
+
`SELECT address, contact_card, received_at FROM pending_handshakes
|
|
1174
|
+
WHERE datetime(received_at, '+' || ? || ' days') < datetime('now')`
|
|
1175
|
+
).all(String(days));
|
|
1176
|
+
return rows.map((r) => ({
|
|
1177
|
+
address: r.address,
|
|
1178
|
+
contactCard: r.contact_card,
|
|
1179
|
+
receivedAt: r.received_at
|
|
1180
|
+
}));
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Return the trust_state for an address, or null if unknown.
|
|
1184
|
+
*/
|
|
1185
|
+
getTrustState(address) {
|
|
1186
|
+
if (this._db === null) return null;
|
|
1187
|
+
const row = this._db.prepare("SELECT trust_state FROM contacts WHERE address = ?").get(address);
|
|
1188
|
+
return row?.trust_state ?? null;
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Set the pinned_at timestamp and trust_state='pinned' for a contact (TOFU).
|
|
1192
|
+
*/
|
|
1193
|
+
setPinnedAt(address) {
|
|
1194
|
+
if (this._db === null) {
|
|
1195
|
+
throw new Error("ContactBook not open. Call open() first.");
|
|
1196
|
+
}
|
|
1197
|
+
this._db.prepare(
|
|
1198
|
+
"UPDATE contacts SET pinned_at = datetime('now'), trust_state = 'pinned' WHERE address = ?"
|
|
1199
|
+
).run(address);
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Remove a contact by address. Returns true if a contact was deleted.
|
|
1203
|
+
*/
|
|
1204
|
+
removeContact(address) {
|
|
1205
|
+
if (this._db === null) {
|
|
1206
|
+
throw new Error("ContactBook not open. Call open() first.");
|
|
1207
|
+
}
|
|
1208
|
+
const result = this._db.prepare("DELETE FROM contacts WHERE address = ?").run(address);
|
|
1209
|
+
this._knownAddresses.delete(address);
|
|
1210
|
+
return result.changes > 0;
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Return true if the address has trust_state 'trusted', 'verified', or 'pinned'.
|
|
1214
|
+
*
|
|
1215
|
+
* Convenience helper for inbound message filtering (CARD-05, TOFU-02).
|
|
1216
|
+
*/
|
|
1217
|
+
isTrustedOrVerified(address) {
|
|
1218
|
+
const state = this.getTrustState(address);
|
|
1219
|
+
return state === "trusted" || state === "verified" || state === "pinned";
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Block an address or domain pattern (e.g., '*::evil.com').
|
|
1223
|
+
*/
|
|
1224
|
+
addBlock(pattern) {
|
|
1225
|
+
if (this._db === null) {
|
|
1226
|
+
throw new Error("ContactBook not open. Call open() first.");
|
|
1227
|
+
}
|
|
1228
|
+
this._db.prepare("INSERT OR IGNORE INTO blocked_patterns (pattern) VALUES (?)").run(pattern);
|
|
1229
|
+
this._cacheBlockPattern(pattern);
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Remove a block pattern.
|
|
1233
|
+
*/
|
|
1234
|
+
removeBlock(pattern) {
|
|
1235
|
+
if (this._db === null) {
|
|
1236
|
+
throw new Error("ContactBook not open. Call open() first.");
|
|
1237
|
+
}
|
|
1238
|
+
this._db.prepare("DELETE FROM blocked_patterns WHERE pattern = ?").run(pattern);
|
|
1239
|
+
this._uncacheBlockPattern(pattern);
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Return all blocked patterns with their timestamps.
|
|
1243
|
+
*/
|
|
1244
|
+
listBlocked() {
|
|
1245
|
+
if (this._db === null) return [];
|
|
1246
|
+
const rows = this._db.prepare(
|
|
1247
|
+
"SELECT pattern, blocked_at FROM blocked_patterns ORDER BY blocked_at DESC"
|
|
1248
|
+
).all();
|
|
1249
|
+
return rows.map((r) => ({
|
|
1250
|
+
pattern: r.pattern,
|
|
1251
|
+
blockedAt: r.blocked_at
|
|
1252
|
+
}));
|
|
1253
|
+
}
|
|
1254
|
+
// -- Internal block caching ---------------------------------------------------
|
|
1255
|
+
_cacheBlockPattern(pattern) {
|
|
1256
|
+
if (pattern.startsWith("*::")) {
|
|
1257
|
+
this._blockedDomains.add(pattern.slice(3));
|
|
1258
|
+
} else {
|
|
1259
|
+
this._blockedExact.add(pattern);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
_uncacheBlockPattern(pattern) {
|
|
1263
|
+
if (pattern.startsWith("*::")) {
|
|
1264
|
+
this._blockedDomains.delete(pattern.slice(3));
|
|
1265
|
+
} else {
|
|
1266
|
+
this._blockedExact.delete(pattern);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
// src/sdk/handshake.ts
|
|
1272
|
+
function bindCardToEnvelope(card, envelope, senderVk) {
|
|
1273
|
+
if (card.address !== envelope.fromAddress) {
|
|
1274
|
+
throw new InvalidEnvelopeError(
|
|
1275
|
+
`Contact card address ${JSON.stringify(card.address)} does not match envelope sender ${JSON.stringify(envelope.fromAddress)}`
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
if (card.publicKey !== serializeVerifyKey(senderVk)) {
|
|
1279
|
+
throw new InvalidEnvelopeError(
|
|
1280
|
+
"Contact card public_key does not match envelope sender's verify key"
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
var HandshakeManager = class {
|
|
1285
|
+
_contactBook;
|
|
1286
|
+
_trustPolicy;
|
|
1287
|
+
constructor(contactBook, trustPolicy) {
|
|
1288
|
+
this._contactBook = contactBook;
|
|
1289
|
+
this._trustPolicy = trustPolicy;
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Create a handshake request envelope with embedded contact card.
|
|
1293
|
+
*
|
|
1294
|
+
* The payload is the JSON-serialized contact card, encrypted with
|
|
1295
|
+
* SealedBox (anonymous encryption) to the recipient.
|
|
1296
|
+
*
|
|
1297
|
+
* Returns wire-format dict ready to send via transport.
|
|
1298
|
+
*/
|
|
1299
|
+
async createHandshakeRequest(agent, toAddress, recipientVk) {
|
|
1300
|
+
const card = await createContactCard(
|
|
1301
|
+
agent.address,
|
|
1302
|
+
agent._config.displayName,
|
|
1303
|
+
agent._config.relayWsUrl,
|
|
1304
|
+
agent._keyManager.signingKey
|
|
1305
|
+
);
|
|
1306
|
+
const cardJson = JSON.stringify(contactCardToDict(card));
|
|
1307
|
+
const envelope = await createEnvelope(
|
|
1308
|
+
agent.address,
|
|
1309
|
+
toAddress,
|
|
1310
|
+
"handshake.request" /* HANDSHAKE_REQUEST */,
|
|
1311
|
+
new TextEncoder().encode(cardJson),
|
|
1312
|
+
agent._keyManager.signingKey,
|
|
1313
|
+
recipientVk
|
|
1314
|
+
);
|
|
1315
|
+
return toWireDict(envelope);
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Handle an inbound handshake message.
|
|
1319
|
+
*
|
|
1320
|
+
* Returns null -- handshake messages are not user-visible.
|
|
1321
|
+
*/
|
|
1322
|
+
async handleInbound(agent, envelope, senderVk) {
|
|
1323
|
+
if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */) {
|
|
1324
|
+
await this._handleRequest(agent, envelope, senderVk);
|
|
1325
|
+
} else if (envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */) {
|
|
1326
|
+
await this._handleAccept(agent, envelope, senderVk);
|
|
1327
|
+
} else if (envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
|
|
1328
|
+
}
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Process a handshake.request: decrypt contact card, apply trust policy.
|
|
1333
|
+
*/
|
|
1334
|
+
async _handleRequest(agent, envelope, senderVk) {
|
|
1335
|
+
const plaintext = decryptPayloadAnonymous(
|
|
1336
|
+
envelope.payload,
|
|
1337
|
+
agent._keyManager.signingKey
|
|
1338
|
+
);
|
|
1339
|
+
const cardDict = JSON.parse(
|
|
1340
|
+
new TextDecoder().decode(plaintext)
|
|
1341
|
+
);
|
|
1342
|
+
const card = contactCardFromDict(cardDict);
|
|
1343
|
+
verifyContactCard(card);
|
|
1344
|
+
bindCardToEnvelope(card, envelope, senderVk);
|
|
1345
|
+
if (this._trustPolicy === "auto-accept") {
|
|
1346
|
+
this._contactBook.addContact(card.address, card.publicKey, {
|
|
1347
|
+
displayName: card.displayName,
|
|
1348
|
+
trustState: "provisional",
|
|
1349
|
+
trustSource: "auto-accepted-provisional"
|
|
1350
|
+
});
|
|
1351
|
+
await this._sendAccept(agent, envelope.fromAddress, senderVk);
|
|
1352
|
+
} else if (this._trustPolicy === "allowlist-only") {
|
|
1353
|
+
await this._sendDeny(agent, envelope.fromAddress, senderVk);
|
|
1354
|
+
} else {
|
|
1355
|
+
this._contactBook.addPending(
|
|
1356
|
+
envelope.fromAddress,
|
|
1357
|
+
JSON.stringify(cardDict)
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Process a handshake.accept: store the sender as pinned (TOFU).
|
|
1363
|
+
*
|
|
1364
|
+
* Only honoured when:
|
|
1365
|
+
* 1. we previously sent this address a handshake.request
|
|
1366
|
+
* (trust_state === "handshake-sent"); anything else is an unsolicited
|
|
1367
|
+
* accept and is dropped. This also means an accept can never
|
|
1368
|
+
* downgrade a trusted/verified contact to pinned;
|
|
1369
|
+
* 2. the payload decrypts with our key and the sender's key, and parses
|
|
1370
|
+
* as JSON. Otherwise it is dropped without pinning;
|
|
1371
|
+
* 3. an embedded contact_card, if present, is well-formed, correctly
|
|
1372
|
+
* self-signed, and bound to the envelope sender (throws otherwise).
|
|
1373
|
+
*/
|
|
1374
|
+
async _handleAccept(agent, envelope, senderVk) {
|
|
1375
|
+
const prior = this._contactBook.getTrustState(envelope.fromAddress);
|
|
1376
|
+
if (prior !== "handshake-sent") {
|
|
1377
|
+
log.warn(
|
|
1378
|
+
`Ignoring unsolicited handshake.accept from ${envelope.fromAddress} (trust_state=${JSON.stringify(prior)})`
|
|
1379
|
+
);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
let payload;
|
|
1383
|
+
try {
|
|
1384
|
+
const plaintext = decryptPayload(
|
|
1385
|
+
envelope.payload,
|
|
1386
|
+
agent._keyManager.signingKey,
|
|
1387
|
+
senderVk
|
|
1388
|
+
);
|
|
1389
|
+
payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
1390
|
+
} catch {
|
|
1391
|
+
log.warn(
|
|
1392
|
+
`Dropping handshake.accept ${envelope.messageId} from ${envelope.fromAddress}: payload could not be decrypted or parsed`
|
|
1393
|
+
);
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
if (payload !== null && typeof payload === "object" && "contact_card" in payload) {
|
|
1397
|
+
const card = contactCardFromDict(
|
|
1398
|
+
payload["contact_card"]
|
|
1399
|
+
);
|
|
1400
|
+
verifyContactCard(card);
|
|
1401
|
+
bindCardToEnvelope(card, envelope, senderVk);
|
|
1402
|
+
}
|
|
1403
|
+
const senderPkStr = serializeVerifyKey(senderVk);
|
|
1404
|
+
this._contactBook.addContact(envelope.fromAddress, senderPkStr, {
|
|
1405
|
+
trustState: "pinned"
|
|
1406
|
+
});
|
|
1407
|
+
this._contactBook.setPinnedAt(envelope.fromAddress);
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Send a handshake.accept envelope back to the requester.
|
|
1411
|
+
*/
|
|
1412
|
+
async _sendAccept(agent, toAddress, recipientVk) {
|
|
1413
|
+
const card = await createContactCard(
|
|
1414
|
+
agent.address,
|
|
1415
|
+
agent._config.displayName,
|
|
1416
|
+
agent._config.relayWsUrl,
|
|
1417
|
+
agent._keyManager.signingKey
|
|
1418
|
+
);
|
|
1419
|
+
const acceptPayload = JSON.stringify({
|
|
1420
|
+
status: "accepted",
|
|
1421
|
+
contact_card: contactCardToDict(card)
|
|
1422
|
+
});
|
|
1423
|
+
const envelope = await createEnvelope(
|
|
1424
|
+
agent.address,
|
|
1425
|
+
toAddress,
|
|
1426
|
+
"handshake.accept" /* HANDSHAKE_ACCEPT */,
|
|
1427
|
+
new TextEncoder().encode(acceptPayload),
|
|
1428
|
+
agent._keyManager.signingKey,
|
|
1429
|
+
recipientVk
|
|
1430
|
+
);
|
|
1431
|
+
const wire = toWireDict(envelope);
|
|
1432
|
+
if (agent._transport) {
|
|
1433
|
+
await agent._transport.send(wire);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Send a handshake.deny envelope to the requester.
|
|
1438
|
+
*/
|
|
1439
|
+
async _sendDeny(agent, toAddress, recipientVk) {
|
|
1440
|
+
const denyPayload = JSON.stringify({
|
|
1441
|
+
status: "denied",
|
|
1442
|
+
reason: "allowlist-only"
|
|
1443
|
+
});
|
|
1444
|
+
const envelope = await createEnvelope(
|
|
1445
|
+
agent.address,
|
|
1446
|
+
toAddress,
|
|
1447
|
+
"handshake.deny" /* HANDSHAKE_DENY */,
|
|
1448
|
+
new TextEncoder().encode(denyPayload),
|
|
1449
|
+
agent._keyManager.signingKey,
|
|
1450
|
+
recipientVk
|
|
1451
|
+
);
|
|
1452
|
+
const wire = toWireDict(envelope);
|
|
1453
|
+
if (agent._transport) {
|
|
1454
|
+
await agent._transport.send(wire);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
// src/sdk/resolver-base.ts
|
|
1460
|
+
var AddressResolver = class {
|
|
1461
|
+
};
|
|
1462
|
+
|
|
1463
|
+
// src/sdk/tier3.ts
|
|
1464
|
+
import { createPublicClient, http, defineChain } from "viem";
|
|
1465
|
+
var DEFAULT_RPC_URL = "https://sepolia.base.org";
|
|
1466
|
+
var DEFAULT_CHAIN_ID = 84532;
|
|
1467
|
+
var CACHE_TTL_MS = 3600 * 1e3;
|
|
1468
|
+
var REGISTRY_ABI = [
|
|
1469
|
+
{
|
|
1470
|
+
name: "resolve",
|
|
1471
|
+
type: "function",
|
|
1472
|
+
stateMutability: "view",
|
|
1473
|
+
inputs: [{ name: "name", type: "string" }],
|
|
1474
|
+
outputs: [
|
|
1475
|
+
{ name: "owner", type: "address" },
|
|
1476
|
+
{ name: "publicKey", type: "string" },
|
|
1477
|
+
{ name: "relayUrl", type: "string" },
|
|
1478
|
+
{ name: "expiry", type: "uint256" }
|
|
1479
|
+
]
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
name: "available",
|
|
1483
|
+
type: "function",
|
|
1484
|
+
stateMutability: "view",
|
|
1485
|
+
inputs: [{ name: "name", type: "string" }],
|
|
1486
|
+
outputs: [{ name: "", type: "bool" }]
|
|
1487
|
+
}
|
|
1488
|
+
];
|
|
1489
|
+
function buildChain(chainId, rpcUrl) {
|
|
1490
|
+
return defineChain({
|
|
1491
|
+
id: chainId,
|
|
1492
|
+
name: `chain-${chainId}`,
|
|
1493
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
1494
|
+
rpcUrls: {
|
|
1495
|
+
default: { http: [rpcUrl] }
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
var Tier3Resolver = class extends AddressResolver {
|
|
1500
|
+
_contractAddress;
|
|
1501
|
+
_client;
|
|
1502
|
+
_cacheTtlMs;
|
|
1503
|
+
_cache = /* @__PURE__ */ new Map();
|
|
1504
|
+
constructor(config) {
|
|
1505
|
+
super();
|
|
1506
|
+
this._contractAddress = config.contractAddress;
|
|
1507
|
+
this._cacheTtlMs = config.cacheTtlMs ?? CACHE_TTL_MS;
|
|
1508
|
+
const rpcUrl = config.rpcUrl ?? DEFAULT_RPC_URL;
|
|
1509
|
+
const chainId = config.chainId ?? DEFAULT_CHAIN_ID;
|
|
1510
|
+
this._client = createPublicClient({
|
|
1511
|
+
chain: buildChain(chainId, rpcUrl),
|
|
1512
|
+
transport: http(rpcUrl)
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
async resolvePublicKey(address, _token, _relayUrl) {
|
|
1516
|
+
const parsed = parseAddress(address);
|
|
1517
|
+
const name = parsed.domain;
|
|
1518
|
+
const cached = this._cache.get(name);
|
|
1519
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
1520
|
+
return cached.publicKey;
|
|
1521
|
+
}
|
|
1522
|
+
try {
|
|
1523
|
+
const result = await this._client.readContract({
|
|
1524
|
+
address: this._contractAddress,
|
|
1525
|
+
abi: REGISTRY_ABI,
|
|
1526
|
+
functionName: "resolve",
|
|
1527
|
+
args: [name]
|
|
1528
|
+
});
|
|
1529
|
+
const [_owner, publicKey, _relayUrl2, _expiry] = result;
|
|
1530
|
+
if (!publicKey) {
|
|
1531
|
+
throw new UAMError(
|
|
1532
|
+
`Tier 3 name '${name}' has no public key registered`
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
this._cache.set(name, {
|
|
1536
|
+
publicKey,
|
|
1537
|
+
expiresAt: Date.now() + this._cacheTtlMs
|
|
1538
|
+
});
|
|
1539
|
+
return publicKey;
|
|
1540
|
+
} catch (err) {
|
|
1541
|
+
if (err instanceof UAMError) throw err;
|
|
1542
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1543
|
+
if (msg.includes("NameNotFound") || msg.includes("revert")) {
|
|
1544
|
+
throw new UAMError(`Tier 3 name not found on-chain: ${name}`);
|
|
1545
|
+
}
|
|
1546
|
+
throw new UAMError(`Tier 3 resolution failed for ${name}: ${msg}`);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Check if a name is available for registration.
|
|
1551
|
+
*/
|
|
1552
|
+
async isAvailable(name) {
|
|
1553
|
+
try {
|
|
1554
|
+
const result = await this._client.readContract({
|
|
1555
|
+
address: this._contractAddress,
|
|
1556
|
+
abi: REGISTRY_ABI,
|
|
1557
|
+
functionName: "available",
|
|
1558
|
+
args: [name]
|
|
1559
|
+
});
|
|
1560
|
+
return result;
|
|
1561
|
+
} catch {
|
|
1562
|
+
return false;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Clear cache for a specific name or all names.
|
|
1567
|
+
*/
|
|
1568
|
+
invalidateCache(name) {
|
|
1569
|
+
if (name) {
|
|
1570
|
+
this._cache.delete(name);
|
|
1571
|
+
} else {
|
|
1572
|
+
this._cache.clear();
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
// src/sdk/resolver.ts
|
|
1578
|
+
init_dns_verifier();
|
|
1579
|
+
var Tier1Resolver = class extends AddressResolver {
|
|
1580
|
+
async resolvePublicKey(address, _token, relayUrl) {
|
|
1581
|
+
const resp = await fetch(
|
|
1582
|
+
`${relayUrl}/api/v1/agents/${address}/public-key`
|
|
1583
|
+
);
|
|
1584
|
+
if (resp.status === 404) {
|
|
1585
|
+
throw new UAMError(`Agent not found: ${address}`);
|
|
1586
|
+
}
|
|
1587
|
+
if (!resp.ok) {
|
|
1588
|
+
throw new UAMError(
|
|
1589
|
+
`Resolver request failed: ${resp.status} ${resp.statusText}`
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
const data = await resp.json();
|
|
1593
|
+
return data["public_key"];
|
|
1594
|
+
}
|
|
1595
|
+
};
|
|
1596
|
+
var Tier2Resolver = class extends AddressResolver {
|
|
1597
|
+
async resolvePublicKey(address, _token, _relayUrl) {
|
|
1598
|
+
const parsed = parseAddress(address);
|
|
1599
|
+
const txtRecords = await queryUamTxt(parsed.domain);
|
|
1600
|
+
for (const txt of txtRecords) {
|
|
1601
|
+
const tags = parseUamTxt(txt);
|
|
1602
|
+
if (tags["v"] === "uam1") {
|
|
1603
|
+
const key = extractPublicKey(tags);
|
|
1604
|
+
if (key) return key;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
const httpsKey = await resolveKeyViaHttps(parsed.agent, parsed.domain);
|
|
1608
|
+
if (httpsKey) return httpsKey;
|
|
1609
|
+
throw new UAMError(
|
|
1610
|
+
`Could not resolve public key for ${address} via DNS or HTTPS`
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
};
|
|
1614
|
+
var SmartResolver = class extends AddressResolver {
|
|
1615
|
+
relayDomain;
|
|
1616
|
+
_tier1;
|
|
1617
|
+
_tier2;
|
|
1618
|
+
_tier3;
|
|
1619
|
+
constructor(relayDomain, tier3Config) {
|
|
1620
|
+
super();
|
|
1621
|
+
this.relayDomain = relayDomain;
|
|
1622
|
+
this._tier1 = new Tier1Resolver();
|
|
1623
|
+
this._tier2 = new Tier2Resolver();
|
|
1624
|
+
this._tier3 = tier3Config ? new Tier3Resolver(tier3Config) : null;
|
|
1625
|
+
}
|
|
1626
|
+
async resolvePublicKey(address, token, relayUrl) {
|
|
1627
|
+
const parsed = parseAddress(address);
|
|
1628
|
+
const domain = parsed.domain;
|
|
1629
|
+
if (domain === this.relayDomain) {
|
|
1630
|
+
return this._tier1.resolvePublicKey(address, token, relayUrl);
|
|
1631
|
+
}
|
|
1632
|
+
if (domain.includes(".")) {
|
|
1633
|
+
return this._tier2.resolvePublicKey(address, token, relayUrl);
|
|
1634
|
+
}
|
|
1635
|
+
if (!this._tier3) {
|
|
1636
|
+
throw new UAMError(
|
|
1637
|
+
`Tier 3 resolution requires contract config. Cannot resolve dot-free domain: '${domain}'`
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
return this._tier3.resolvePublicKey(address, token, relayUrl);
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
|
|
1644
|
+
// src/sdk/transport/base.ts
|
|
1645
|
+
var TransportBase = class {
|
|
1646
|
+
};
|
|
1647
|
+
|
|
1648
|
+
// src/sdk/transport/http.ts
|
|
1649
|
+
var HTTPTransport = class extends TransportBase {
|
|
1650
|
+
_relayUrl;
|
|
1651
|
+
_token;
|
|
1652
|
+
_address;
|
|
1653
|
+
constructor(relayUrl, token, address) {
|
|
1654
|
+
super();
|
|
1655
|
+
this._relayUrl = relayUrl.replace(/\/+$/, "");
|
|
1656
|
+
this._token = token;
|
|
1657
|
+
this._address = address;
|
|
1658
|
+
}
|
|
1659
|
+
async connect() {
|
|
1660
|
+
}
|
|
1661
|
+
async disconnect() {
|
|
1662
|
+
}
|
|
1663
|
+
async send(envelope) {
|
|
1664
|
+
const resp = await fetch(`${this._relayUrl}/api/v1/send`, {
|
|
1665
|
+
method: "POST",
|
|
1666
|
+
headers: {
|
|
1667
|
+
"Content-Type": "application/json",
|
|
1668
|
+
Authorization: `Bearer ${this._token}`
|
|
1669
|
+
},
|
|
1670
|
+
body: JSON.stringify({ envelope })
|
|
1671
|
+
});
|
|
1672
|
+
if (!resp.ok) {
|
|
1673
|
+
throw new Error(
|
|
1674
|
+
`HTTP send failed: ${resp.status} ${resp.statusText}`
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
async receive(limit = 50) {
|
|
1679
|
+
const url = new URL(
|
|
1680
|
+
`${this._relayUrl}/api/v1/inbox/${this._address}`
|
|
1681
|
+
);
|
|
1682
|
+
url.searchParams.set("limit", String(limit));
|
|
1683
|
+
const resp = await fetch(url.toString(), {
|
|
1684
|
+
headers: {
|
|
1685
|
+
Authorization: `Bearer ${this._token}`
|
|
1686
|
+
}
|
|
1687
|
+
});
|
|
1688
|
+
if (!resp.ok) {
|
|
1689
|
+
throw new Error(
|
|
1690
|
+
`HTTP receive failed: ${resp.status} ${resp.statusText}`
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
const data = await resp.json();
|
|
1694
|
+
return data["messages"] ?? [];
|
|
1695
|
+
}
|
|
1696
|
+
async listen(_callback) {
|
|
1697
|
+
throw new Error(
|
|
1698
|
+
"HTTP transport does not support real-time listening. Use receive() for polling or switch to WebSocket transport."
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
// src/sdk/transport/websocket.ts
|
|
1704
|
+
import WebSocket from "ws";
|
|
1705
|
+
var BASE_DELAY = 1;
|
|
1706
|
+
var MAX_DELAY = 60;
|
|
1707
|
+
var JITTER_RANGE = 1;
|
|
1708
|
+
var SEND_SETTLE_SECONDS = 5;
|
|
1709
|
+
var WebSocketTransport = class extends TransportBase {
|
|
1710
|
+
_url;
|
|
1711
|
+
_token;
|
|
1712
|
+
_onMessage;
|
|
1713
|
+
_ws = null;
|
|
1714
|
+
_pending = [];
|
|
1715
|
+
_connected = false;
|
|
1716
|
+
_connectResolve = null;
|
|
1717
|
+
_connectReject = null;
|
|
1718
|
+
_running = false;
|
|
1719
|
+
_reconnectTimeout = null;
|
|
1720
|
+
/** Envelopes sent that the relay hasn't answered yet. */
|
|
1721
|
+
_unanswered = 0;
|
|
1722
|
+
_settling = null;
|
|
1723
|
+
_settled = null;
|
|
1724
|
+
/** @internal How long a close waits, in ms. Replaceable in tests. */
|
|
1725
|
+
_settleMs = SEND_SETTLE_SECONDS * 1e3;
|
|
1726
|
+
constructor(wsUrl, token, onMessage) {
|
|
1727
|
+
super();
|
|
1728
|
+
this._url = wsUrl;
|
|
1729
|
+
this._token = token;
|
|
1730
|
+
this._onMessage = onMessage ?? null;
|
|
1731
|
+
}
|
|
1732
|
+
async connect() {
|
|
1733
|
+
this._running = true;
|
|
1734
|
+
return new Promise((resolve, reject) => {
|
|
1735
|
+
this._connectResolve = resolve;
|
|
1736
|
+
this._connectReject = reject;
|
|
1737
|
+
this._startConnectionLoop();
|
|
1738
|
+
const timeout = setTimeout(() => {
|
|
1739
|
+
this._connectReject = null;
|
|
1740
|
+
this._connectResolve = null;
|
|
1741
|
+
reject(new Error("WebSocket connection timeout (30s)"));
|
|
1742
|
+
}, 3e4);
|
|
1743
|
+
const origResolve = this._connectResolve;
|
|
1744
|
+
this._connectResolve = () => {
|
|
1745
|
+
clearTimeout(timeout);
|
|
1746
|
+
origResolve?.();
|
|
1747
|
+
};
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Close the connection, once the relay has answered what was sent.
|
|
1752
|
+
*
|
|
1753
|
+
* The relay stops reading a socket when it can't write an ack to it, so
|
|
1754
|
+
* closing straight after send() loses every frame but the first.
|
|
1755
|
+
*/
|
|
1756
|
+
async disconnect() {
|
|
1757
|
+
await this._allAnswered();
|
|
1758
|
+
this._running = false;
|
|
1759
|
+
if (this._reconnectTimeout !== null) {
|
|
1760
|
+
clearTimeout(this._reconnectTimeout);
|
|
1761
|
+
this._reconnectTimeout = null;
|
|
1762
|
+
}
|
|
1763
|
+
if (this._ws) {
|
|
1764
|
+
this._ws.removeAllListeners();
|
|
1765
|
+
this._ws.close();
|
|
1766
|
+
this._ws = null;
|
|
1767
|
+
}
|
|
1768
|
+
this._connected = false;
|
|
1769
|
+
}
|
|
1770
|
+
async send(envelope) {
|
|
1771
|
+
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
|
1772
|
+
throw new Error("WebSocket not connected");
|
|
1773
|
+
}
|
|
1774
|
+
this._ws.send(JSON.stringify(envelope));
|
|
1775
|
+
this._unanswered += 1;
|
|
1776
|
+
}
|
|
1777
|
+
async receive(limit = 50) {
|
|
1778
|
+
const result = this._pending.slice(0, limit);
|
|
1779
|
+
this._pending = this._pending.slice(limit);
|
|
1780
|
+
return result;
|
|
1781
|
+
}
|
|
1782
|
+
async listen(callback) {
|
|
1783
|
+
this._onMessage = callback;
|
|
1784
|
+
}
|
|
1785
|
+
/** Resolves when nothing is unanswered, or after _settleMs regardless. */
|
|
1786
|
+
_allAnswered() {
|
|
1787
|
+
if (this._unanswered === 0) return Promise.resolve();
|
|
1788
|
+
this._settling ??= new Promise((resolve) => {
|
|
1789
|
+
const timeout = setTimeout(() => {
|
|
1790
|
+
log.warn(`Closing with ${this._unanswered} sends unanswered`);
|
|
1791
|
+
this._settled?.();
|
|
1792
|
+
}, this._settleMs);
|
|
1793
|
+
this._settled = () => {
|
|
1794
|
+
clearTimeout(timeout);
|
|
1795
|
+
this._settling = null;
|
|
1796
|
+
this._settled = null;
|
|
1797
|
+
resolve();
|
|
1798
|
+
};
|
|
1799
|
+
});
|
|
1800
|
+
return this._settling;
|
|
1801
|
+
}
|
|
1802
|
+
_answered() {
|
|
1803
|
+
this._unanswered = Math.max(0, this._unanswered - 1);
|
|
1804
|
+
if (this._unanswered === 0) this._settled?.();
|
|
1805
|
+
}
|
|
1806
|
+
_startConnectionLoop() {
|
|
1807
|
+
let attempt = 0;
|
|
1808
|
+
const tryConnect = () => {
|
|
1809
|
+
if (!this._running) return;
|
|
1810
|
+
const ws = new WebSocket(this._url, [`bearer.${this._token}`]);
|
|
1811
|
+
this._ws = ws;
|
|
1812
|
+
ws.on("open", () => {
|
|
1813
|
+
this._connected = true;
|
|
1814
|
+
attempt = 0;
|
|
1815
|
+
if (this._connectResolve) {
|
|
1816
|
+
this._connectResolve();
|
|
1817
|
+
this._connectResolve = null;
|
|
1818
|
+
this._connectReject = null;
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
ws.on("message", (data) => {
|
|
1822
|
+
try {
|
|
1823
|
+
const msg = JSON.parse(data.toString());
|
|
1824
|
+
this._handleMessage(msg);
|
|
1825
|
+
} catch {
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
ws.on("close", () => {
|
|
1829
|
+
this._connected = false;
|
|
1830
|
+
this._ws = null;
|
|
1831
|
+
this._unanswered = 0;
|
|
1832
|
+
this._settled?.();
|
|
1833
|
+
if (!this._running) return;
|
|
1834
|
+
attempt += 1;
|
|
1835
|
+
const delay = Math.min(BASE_DELAY * Math.pow(2, attempt), MAX_DELAY);
|
|
1836
|
+
const jitter = Math.random() * JITTER_RANGE;
|
|
1837
|
+
const totalDelay = (delay + jitter) * 1e3;
|
|
1838
|
+
this._reconnectTimeout = setTimeout(tryConnect, totalDelay);
|
|
1839
|
+
});
|
|
1840
|
+
ws.on("error", (err) => {
|
|
1841
|
+
if (this._connectReject && !this._connected) {
|
|
1842
|
+
}
|
|
1843
|
+
});
|
|
1844
|
+
};
|
|
1845
|
+
tryConnect();
|
|
1846
|
+
}
|
|
1847
|
+
_handleMessage(msg) {
|
|
1848
|
+
const msgType = msg["type"];
|
|
1849
|
+
if ("uam_version" in msg) {
|
|
1850
|
+
if (this._onMessage) {
|
|
1851
|
+
this._onMessage(msg).catch(() => {
|
|
1852
|
+
});
|
|
1853
|
+
} else {
|
|
1854
|
+
this._pending.push(msg);
|
|
1855
|
+
}
|
|
1856
|
+
} else if (msgType === "ping") {
|
|
1857
|
+
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
1858
|
+
this._ws.send(JSON.stringify({ type: "pong" }));
|
|
1859
|
+
}
|
|
1860
|
+
} else if (msgType === "ack") {
|
|
1861
|
+
this._answered();
|
|
1862
|
+
} else if (msgType === "error" || "error" in msg) {
|
|
1863
|
+
this._answered();
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
|
|
1868
|
+
// src/sdk/transport/index.ts
|
|
1869
|
+
function createTransport(config, token, address, onMessage) {
|
|
1870
|
+
if (config.transportType === "http") {
|
|
1871
|
+
return new HTTPTransport(config.relayUrl, token, address);
|
|
1872
|
+
} else {
|
|
1873
|
+
return new WebSocketTransport(config.relayWsUrl, token, onMessage);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// src/sdk/replay-cache.ts
|
|
1878
|
+
import { mkdirSync as mkdirSync3 } from "fs";
|
|
1879
|
+
import { dirname as dirname2 } from "path";
|
|
1880
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
1881
|
+
import Database2 from "better-sqlite3";
|
|
1882
|
+
var RETENTION_SECONDS = STORED_MAIL_MAX_AGE + 2 * MAX_ENVELOPE_AGE;
|
|
1883
|
+
var PURGE_INTERVAL_MS = 6e4;
|
|
1884
|
+
var WAL_ATTEMPTS = 100;
|
|
1885
|
+
var WAL_RETRY_MS = 100;
|
|
1886
|
+
var SCHEMA = `
|
|
1887
|
+
CREATE TABLE IF NOT EXISTS seen_envelopes (
|
|
1888
|
+
from_address TEXT NOT NULL,
|
|
1889
|
+
message_id TEXT NOT NULL,
|
|
1890
|
+
seen_at REAL NOT NULL,
|
|
1891
|
+
PRIMARY KEY (from_address, message_id)
|
|
1892
|
+
) WITHOUT ROWID;
|
|
1893
|
+
CREATE INDEX IF NOT EXISTS seen_envelopes_seen_at ON seen_envelopes (seen_at);
|
|
1894
|
+
CREATE TABLE IF NOT EXISTS replay_meta (
|
|
1895
|
+
key TEXT PRIMARY KEY,
|
|
1896
|
+
value TEXT NOT NULL
|
|
1897
|
+
);
|
|
1898
|
+
`;
|
|
1899
|
+
function isLocked(err) {
|
|
1900
|
+
const code = err.code;
|
|
1901
|
+
return typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"));
|
|
1902
|
+
}
|
|
1903
|
+
async function enableWal(db) {
|
|
1904
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
1905
|
+
try {
|
|
1906
|
+
db.pragma("journal_mode = WAL");
|
|
1907
|
+
return;
|
|
1908
|
+
} catch (err) {
|
|
1909
|
+
if (attempt >= WAL_ATTEMPTS || !isLocked(err)) throw err;
|
|
1910
|
+
await sleep(WAL_RETRY_MS);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
var DEFAULT_REPLAY_CACHE_CAPACITY = 1e6;
|
|
1915
|
+
var EnvelopeReplayCache = class {
|
|
1916
|
+
capacity;
|
|
1917
|
+
ttlMs;
|
|
1918
|
+
_now;
|
|
1919
|
+
// Map iteration order is insertion order: first key = least recently used.
|
|
1920
|
+
_seen = /* @__PURE__ */ new Map();
|
|
1921
|
+
constructor(options) {
|
|
1922
|
+
const capacity = options?.capacity ?? 1e5;
|
|
1923
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
1924
|
+
throw new RangeError(
|
|
1925
|
+
`Replay cache capacity must be a positive integer, got ${capacity}`
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1928
|
+
this.capacity = capacity;
|
|
1929
|
+
this.ttlMs = options?.ttlMs ?? 3e5;
|
|
1930
|
+
this._now = options?.now ?? (() => Date.now());
|
|
1931
|
+
}
|
|
1932
|
+
get size() {
|
|
1933
|
+
return this._seen.size;
|
|
1934
|
+
}
|
|
1935
|
+
/** True if this pair was seen within the TTL. Records nothing. */
|
|
1936
|
+
has(fromAddress, messageId) {
|
|
1937
|
+
const seenAt = this._seen.get(this._key(fromAddress, messageId));
|
|
1938
|
+
return seenAt !== void 0 && this._now() - seenAt < this.ttlMs;
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Return true if this (fromAddress, messageId) pair was already seen
|
|
1942
|
+
* within the TTL (a replay). Otherwise record it and return false.
|
|
1943
|
+
*/
|
|
1944
|
+
seenOrRecord(fromAddress, messageId) {
|
|
1945
|
+
const key = this._key(fromAddress, messageId);
|
|
1946
|
+
const now = this._now();
|
|
1947
|
+
const seenAt = this._seen.get(key);
|
|
1948
|
+
if (seenAt !== void 0 && now - seenAt < this.ttlMs) {
|
|
1949
|
+
this._seen.delete(key);
|
|
1950
|
+
this._seen.set(key, seenAt);
|
|
1951
|
+
return true;
|
|
1952
|
+
}
|
|
1953
|
+
this._seen.delete(key);
|
|
1954
|
+
this._seen.set(key, now);
|
|
1955
|
+
while (this._seen.size > this.capacity) {
|
|
1956
|
+
const oldest = this._seen.keys().next().value;
|
|
1957
|
+
this._seen.delete(oldest);
|
|
1958
|
+
}
|
|
1959
|
+
return false;
|
|
1960
|
+
}
|
|
1961
|
+
_key(fromAddress, messageId) {
|
|
1962
|
+
return JSON.stringify([fromAddress, messageId]);
|
|
1963
|
+
}
|
|
1964
|
+
};
|
|
1965
|
+
var PersistentReplayCache = class {
|
|
1966
|
+
capacity;
|
|
1967
|
+
_path;
|
|
1968
|
+
_now;
|
|
1969
|
+
_db = null;
|
|
1970
|
+
_opening = null;
|
|
1971
|
+
_count = 0;
|
|
1972
|
+
_lastPurge = 0;
|
|
1973
|
+
_createdAt = 0;
|
|
1974
|
+
// Used only while the file is full, for envelopes inside MAX_ENVELOPE_AGE.
|
|
1975
|
+
// One can be signed that far ahead of us and stay acceptable until that far
|
|
1976
|
+
// behind, so its id must last both.
|
|
1977
|
+
_overflow;
|
|
1978
|
+
/**
|
|
1979
|
+
* @param path - The SQLite file
|
|
1980
|
+
* @param options.now - The clock, in ms since the epoch
|
|
1981
|
+
*/
|
|
1982
|
+
constructor(path, options) {
|
|
1983
|
+
const capacity = options?.capacity ?? DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
1984
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
1985
|
+
throw new RangeError(
|
|
1986
|
+
`Replay cache capacity must be a positive integer, got ${capacity}`
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
1989
|
+
this.capacity = capacity;
|
|
1990
|
+
this._path = path;
|
|
1991
|
+
this._now = options?.now ?? (() => Date.now());
|
|
1992
|
+
this._overflow = new EnvelopeReplayCache({
|
|
1993
|
+
capacity: 1e4,
|
|
1994
|
+
ttlMs: 2 * MAX_ENVELOPE_AGE * 1e3,
|
|
1995
|
+
now: this._now
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
/** Open the file now, rather than on first use. */
|
|
1999
|
+
async open() {
|
|
2000
|
+
await this._open();
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Return true if the envelope must be dropped as a replay. Otherwise record
|
|
2004
|
+
* the pair and return false.
|
|
2005
|
+
*
|
|
2006
|
+
* @param signedAt - The envelope's signed time, in ms since the epoch. It
|
|
2007
|
+
* matters only when the cache is full.
|
|
2008
|
+
*/
|
|
2009
|
+
async seenOrRecord(fromAddress, messageId, signedAt) {
|
|
2010
|
+
const db = await this._open();
|
|
2011
|
+
const now = this._now();
|
|
2012
|
+
if (now - this._lastPurge >= PURGE_INTERVAL_MS) this._purge(db);
|
|
2013
|
+
if (this._overflow.has(fromAddress, messageId)) return true;
|
|
2014
|
+
if (this._count < this.capacity) {
|
|
2015
|
+
const { changes } = db.prepare(
|
|
2016
|
+
"INSERT OR IGNORE INTO seen_envelopes (from_address, message_id, seen_at) VALUES (?, ?, ?)"
|
|
2017
|
+
).run(fromAddress, messageId, now / 1e3);
|
|
2018
|
+
if (changes === 0) return true;
|
|
2019
|
+
this._count += 1;
|
|
2020
|
+
return false;
|
|
2021
|
+
}
|
|
2022
|
+
const known = db.prepare(
|
|
2023
|
+
"SELECT 1 FROM seen_envelopes WHERE from_address = ? AND message_id = ?"
|
|
2024
|
+
).get(fromAddress, messageId);
|
|
2025
|
+
if (known !== void 0) return true;
|
|
2026
|
+
if (Math.abs(now - signedAt) <= MAX_ENVELOPE_AGE * 1e3) {
|
|
2027
|
+
return this._overflow.seenOrRecord(fromAddress, messageId);
|
|
2028
|
+
}
|
|
2029
|
+
log.warn(
|
|
2030
|
+
`Replay cache full (${this._count} ids); refused envelope ${messageId} from ${fromAddress} signed ${Math.round((now - signedAt) / 1e3)}s ago`
|
|
2031
|
+
);
|
|
2032
|
+
return true;
|
|
2033
|
+
}
|
|
2034
|
+
/** When this cache file was first created, in ms since the epoch. */
|
|
2035
|
+
async createdAt() {
|
|
2036
|
+
await this._open();
|
|
2037
|
+
return this._createdAt;
|
|
2038
|
+
}
|
|
2039
|
+
/** True if an envelope signed at `signedAt` (ms) predates this cache. */
|
|
2040
|
+
async signedBeforeCache(signedAt) {
|
|
2041
|
+
return signedAt < await this.createdAt();
|
|
2042
|
+
}
|
|
2043
|
+
/** Number of ids currently held on disk. */
|
|
2044
|
+
async count() {
|
|
2045
|
+
const db = await this._open();
|
|
2046
|
+
return this._countRows(db);
|
|
2047
|
+
}
|
|
2048
|
+
/** Close the file; the next call opens it again. */
|
|
2049
|
+
async close() {
|
|
2050
|
+
await this._opening?.catch(() => void 0);
|
|
2051
|
+
this._db?.close();
|
|
2052
|
+
this._db = null;
|
|
2053
|
+
}
|
|
2054
|
+
_open() {
|
|
2055
|
+
if (this._db !== null) return Promise.resolve(this._db);
|
|
2056
|
+
this._opening ??= this._connect().finally(() => {
|
|
2057
|
+
this._opening = null;
|
|
2058
|
+
});
|
|
2059
|
+
return this._opening;
|
|
2060
|
+
}
|
|
2061
|
+
async _connect() {
|
|
2062
|
+
mkdirSync3(dirname2(this._path), { recursive: true });
|
|
2063
|
+
const db = new Database2(this._path, { timeout: 3e4 });
|
|
2064
|
+
try {
|
|
2065
|
+
await enableWal(db);
|
|
2066
|
+
db.exec(SCHEMA);
|
|
2067
|
+
db.prepare(
|
|
2068
|
+
"INSERT OR IGNORE INTO replay_meta (key, value) VALUES ('created_at', ?)"
|
|
2069
|
+
).run(String(this._now() / 1e3));
|
|
2070
|
+
const row = db.prepare("SELECT value FROM replay_meta WHERE key = 'created_at'").get();
|
|
2071
|
+
this._createdAt = Number(row.value) * 1e3;
|
|
2072
|
+
this._purge(db);
|
|
2073
|
+
} catch (err) {
|
|
2074
|
+
db.close();
|
|
2075
|
+
throw err;
|
|
2076
|
+
}
|
|
2077
|
+
this._db = db;
|
|
2078
|
+
return db;
|
|
2079
|
+
}
|
|
2080
|
+
_purge(db) {
|
|
2081
|
+
const now = this._now();
|
|
2082
|
+
db.prepare("DELETE FROM seen_envelopes WHERE seen_at < ?").run(
|
|
2083
|
+
now / 1e3 - RETENTION_SECONDS
|
|
2084
|
+
);
|
|
2085
|
+
this._count = this._countRows(db);
|
|
2086
|
+
this._lastPurge = now;
|
|
2087
|
+
}
|
|
2088
|
+
_countRows(db) {
|
|
2089
|
+
const row = db.prepare("SELECT COUNT(*) AS n FROM seen_envelopes").get();
|
|
2090
|
+
return row.n;
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
// src/sdk/agent.ts
|
|
2095
|
+
var RELAY_NO_MEMORY_MS = 6e4;
|
|
2096
|
+
function replayCacheCapacityFromEnv() {
|
|
2097
|
+
const raw = process.env["UAM_REPLAY_CACHE_CAPACITY"];
|
|
2098
|
+
if (raw === void 0 || raw === "") {
|
|
2099
|
+
return DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
2100
|
+
}
|
|
2101
|
+
const n = Number(raw);
|
|
2102
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
2103
|
+
log.warn(
|
|
2104
|
+
`Ignoring invalid UAM_REPLAY_CACHE_CAPACITY=${JSON.stringify(raw)}; using ${DEFAULT_REPLAY_CACHE_CAPACITY}`
|
|
2105
|
+
);
|
|
2106
|
+
return DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
2107
|
+
}
|
|
2108
|
+
return n;
|
|
2109
|
+
}
|
|
2110
|
+
var Agent = class {
|
|
2111
|
+
/** @internal */
|
|
2112
|
+
_config;
|
|
2113
|
+
/** @internal */
|
|
2114
|
+
_keyManager;
|
|
2115
|
+
/** @internal */
|
|
2116
|
+
_transport = null;
|
|
2117
|
+
_resolver;
|
|
2118
|
+
_address = null;
|
|
2119
|
+
_token = null;
|
|
2120
|
+
_connected = false;
|
|
2121
|
+
_autoRegister;
|
|
2122
|
+
/** @internal */
|
|
2123
|
+
_contactBook;
|
|
2124
|
+
/** @internal */
|
|
2125
|
+
_handshake;
|
|
2126
|
+
/**
|
|
2127
|
+
* @internal Wall clock in ms since the epoch, used by the inbound
|
|
2128
|
+
* freshness/expiry checks and the replay cache. Replaceable in tests.
|
|
2129
|
+
*/
|
|
2130
|
+
_clock = () => Date.now();
|
|
2131
|
+
/**
|
|
2132
|
+
* @internal Inbound replay cache, keyed by (from address, message id).
|
|
2133
|
+
* Kept on disk under the data dir, so it outlives restarts and is shared by
|
|
2134
|
+
* every process using it; capacity from UAM_REPLAY_CACHE_CAPACITY.
|
|
2135
|
+
*/
|
|
2136
|
+
_replayCache;
|
|
2137
|
+
/** Whether our relay has said it writes relay_meta. */
|
|
2138
|
+
_relayStamps = false;
|
|
2139
|
+
/** Until when, by _monotonic, to take the relay's last no as still true. */
|
|
2140
|
+
_relayNoUntil = 0;
|
|
2141
|
+
/** @internal Monotonic clock in ms. Replaceable in tests. */
|
|
2142
|
+
_monotonic = () => performance.now();
|
|
2143
|
+
/**
|
|
2144
|
+
* Create an Agent. No I/O happens here -- call connect() to initialize.
|
|
2145
|
+
*/
|
|
2146
|
+
constructor(name, options) {
|
|
2147
|
+
this._config = new SDKConfig({
|
|
2148
|
+
name,
|
|
2149
|
+
relayUrl: options?.relay,
|
|
2150
|
+
relayDomain: options?.domain ?? void 0,
|
|
2151
|
+
keyDir: options?.keyDir,
|
|
2152
|
+
displayName: options?.displayName ?? name,
|
|
2153
|
+
transportType: options?.transport ?? "websocket",
|
|
2154
|
+
trustPolicy: options?.trustPolicy ?? "auto-accept"
|
|
2155
|
+
});
|
|
2156
|
+
this._keyManager = new KeyManager(this._config.keyDir);
|
|
2157
|
+
this._resolver = new SmartResolver(this._config.relayDomain);
|
|
2158
|
+
this._autoRegister = options?.autoRegister ?? true;
|
|
2159
|
+
this._contactBook = new ContactBook(this._config.dataDir);
|
|
2160
|
+
this._handshake = new HandshakeManager(
|
|
2161
|
+
this._contactBook,
|
|
2162
|
+
this._config.trustPolicy
|
|
2163
|
+
);
|
|
2164
|
+
this._replayCache = new PersistentReplayCache(
|
|
2165
|
+
join4(this._config.dataDir, "replay", "replay.db"),
|
|
2166
|
+
{
|
|
2167
|
+
capacity: replayCacheCapacityFromEnv(),
|
|
2168
|
+
now: () => this._clock()
|
|
2169
|
+
}
|
|
2170
|
+
);
|
|
2171
|
+
}
|
|
2172
|
+
// -- Properties ----------------------------------------------------------
|
|
2173
|
+
/** The agent's full UAM address (e.g., 'myagent::youam.network'). */
|
|
2174
|
+
get address() {
|
|
2175
|
+
if (this._address === null) {
|
|
2176
|
+
throw new Error(
|
|
2177
|
+
"Agent not yet connected. Call await agent.connect() first."
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
return this._address;
|
|
2181
|
+
}
|
|
2182
|
+
/** UAM SDK version. */
|
|
2183
|
+
get version() {
|
|
2184
|
+
return SDK_VERSION;
|
|
2185
|
+
}
|
|
2186
|
+
/** The agent's public key (base64-encoded Ed25519 verify key). */
|
|
2187
|
+
get publicKey() {
|
|
2188
|
+
return serializeVerifyKey(this._keyManager.verifyKey);
|
|
2189
|
+
}
|
|
2190
|
+
/** Whether the agent has completed connection setup. */
|
|
2191
|
+
get isConnected() {
|
|
2192
|
+
return this._connected;
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* Generate and return a signed contact card for this agent.
|
|
2196
|
+
*/
|
|
2197
|
+
async contactCard() {
|
|
2198
|
+
if (!this._connected) {
|
|
2199
|
+
throw new Error("Agent not connected. Call connect() first.");
|
|
2200
|
+
}
|
|
2201
|
+
const card = await createContactCard(
|
|
2202
|
+
this._address,
|
|
2203
|
+
this._config.displayName,
|
|
2204
|
+
this._config.relayWsUrl,
|
|
2205
|
+
this._keyManager.signingKey
|
|
2206
|
+
);
|
|
2207
|
+
return contactCardToDict(card);
|
|
2208
|
+
}
|
|
2209
|
+
// -- Lifecycle -----------------------------------------------------------
|
|
2210
|
+
/**
|
|
2211
|
+
* Initialize the agent: load/generate keys, register, connect transport.
|
|
2212
|
+
*
|
|
2213
|
+
* Idempotent -- calling twice is safe.
|
|
2214
|
+
*/
|
|
2215
|
+
async connect() {
|
|
2216
|
+
if (this._connected) {
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
await sodiumReady;
|
|
2220
|
+
this._keyManager.loadOrGenerate(this._config.name);
|
|
2221
|
+
try {
|
|
2222
|
+
await this._replayCache.open();
|
|
2223
|
+
} catch (err) {
|
|
2224
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2225
|
+
log.warn(`Could not open the replay cache: ${reason}`);
|
|
2226
|
+
}
|
|
2227
|
+
const storedToken = this._keyManager.loadToken(
|
|
2228
|
+
this._config.name,
|
|
2229
|
+
this._config.relayUrl
|
|
2230
|
+
);
|
|
2231
|
+
if (storedToken) {
|
|
2232
|
+
this._token = storedToken;
|
|
2233
|
+
this._useAddress(await this._storedAddress());
|
|
2234
|
+
} else if (this._autoRegister) {
|
|
2235
|
+
await this._registerWithRelay();
|
|
2236
|
+
} else {
|
|
2237
|
+
throw new UAMError(
|
|
2238
|
+
"No stored token and autoRegister=false. Register manually or set autoRegister=true."
|
|
2239
|
+
);
|
|
2240
|
+
}
|
|
2241
|
+
this._transport = createTransport(
|
|
2242
|
+
this._config,
|
|
2243
|
+
this._token,
|
|
2244
|
+
this._address
|
|
2245
|
+
);
|
|
2246
|
+
await this._transport.connect();
|
|
2247
|
+
this._contactBook.open();
|
|
2248
|
+
this._connected = true;
|
|
2249
|
+
await this._sweepExpiredHandshakes();
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Disconnect the transport and clean up resources.
|
|
2253
|
+
*/
|
|
2254
|
+
async close() {
|
|
2255
|
+
this._contactBook.close();
|
|
2256
|
+
await this._replayCache.close();
|
|
2257
|
+
if (this._transport) {
|
|
2258
|
+
await this._transport.disconnect();
|
|
2259
|
+
}
|
|
2260
|
+
this._connected = false;
|
|
2261
|
+
}
|
|
2262
|
+
// -- Messaging -----------------------------------------------------------
|
|
2263
|
+
/**
|
|
2264
|
+
* Send an encrypted, signed message. Returns the message_id.
|
|
2265
|
+
*/
|
|
2266
|
+
async send(toAddress, message, options) {
|
|
2267
|
+
await this._ensureConnected();
|
|
2268
|
+
if (this._config.trustPolicy === "require_verify") {
|
|
2269
|
+
const trust = this._contactBook.getTrustState(toAddress);
|
|
2270
|
+
if (trust !== "pinned" && trust !== "verified") {
|
|
2271
|
+
throw new UAMError(
|
|
2272
|
+
`Trust policy 'require_verify' requires fingerprint verification before sending to ${toAddress}. Run: uam contact verify ${toAddress}`
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
const recipientVk = await this._resolvePublicKey(toAddress);
|
|
2277
|
+
const trustState = this._contactBook.getTrustState(toAddress);
|
|
2278
|
+
if (trustState !== "pinned" && trustState !== "verified" && trustState !== "trusted" && trustState !== "handshake-sent") {
|
|
2279
|
+
await this._initiateHandshake(toAddress, recipientVk);
|
|
2280
|
+
}
|
|
2281
|
+
const envelope = await createEnvelope(
|
|
2282
|
+
this._address,
|
|
2283
|
+
toAddress,
|
|
2284
|
+
"message" /* MESSAGE */,
|
|
2285
|
+
new TextEncoder().encode(message),
|
|
2286
|
+
this._keyManager.signingKey,
|
|
2287
|
+
recipientVk,
|
|
2288
|
+
{
|
|
2289
|
+
threadId: options?.threadId,
|
|
2290
|
+
mediaType: "text/plain",
|
|
2291
|
+
attachments: options?.attachments
|
|
2292
|
+
}
|
|
2293
|
+
);
|
|
2294
|
+
const wire = toWireDict(envelope);
|
|
2295
|
+
const relayUrls = await this._getRelayUrls(toAddress);
|
|
2296
|
+
if (relayUrls !== null && relayUrls.length === 1 && relayUrls[0] === this._config.relayUrl) {
|
|
2297
|
+
await this._transport.send(wire);
|
|
2298
|
+
} else if (relayUrls !== null && relayUrls.length > 0) {
|
|
2299
|
+
await this._trySendWithFailover(wire, relayUrls);
|
|
2300
|
+
} else {
|
|
2301
|
+
await this._transport.send(wire);
|
|
2302
|
+
}
|
|
2303
|
+
return envelope.messageId;
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* Retrieve, decrypt, and verify pending messages.
|
|
2307
|
+
*
|
|
2308
|
+
* Returns a list of ReceivedMessage data objects.
|
|
2309
|
+
* Drops messages with invalid signatures or decryption errors. A message
|
|
2310
|
+
* that fails processing for any other reason (malformed envelope, bad
|
|
2311
|
+
* handshake payload, ...) is logged and dropped; it never aborts the
|
|
2312
|
+
* rest of the batch.
|
|
2313
|
+
*/
|
|
2314
|
+
async inbox(limit = 50) {
|
|
2315
|
+
await this._ensureConnected();
|
|
2316
|
+
await this._sweepExpiredHandshakes();
|
|
2317
|
+
const rawMessages = await this._transport.receive(limit);
|
|
2318
|
+
const result = [];
|
|
2319
|
+
for (const raw of rawMessages) {
|
|
2320
|
+
let msg;
|
|
2321
|
+
try {
|
|
2322
|
+
msg = await this._processInbound(raw);
|
|
2323
|
+
} catch (err) {
|
|
2324
|
+
log.warn(
|
|
2325
|
+
`Dropping inbound message ${String(raw["message_id"])} from ${String(raw["from"])}: ${err instanceof Error ? err.message : String(err)}`
|
|
2326
|
+
);
|
|
2327
|
+
continue;
|
|
2328
|
+
}
|
|
2329
|
+
if (msg !== null) {
|
|
2330
|
+
result.push(msg);
|
|
2331
|
+
await this._sendReadReceipt(msg);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
return result;
|
|
2335
|
+
}
|
|
2336
|
+
// -- Trust management (HAND-02, HAND-04) ---------------------------------
|
|
2337
|
+
/** List pending handshake requests. */
|
|
2338
|
+
async pending() {
|
|
2339
|
+
await this._ensureConnected();
|
|
2340
|
+
return this._contactBook.getPending();
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Approve a pending handshake request.
|
|
2344
|
+
*
|
|
2345
|
+
* Adds the sender to contacts with trust_source='explicit-approval'
|
|
2346
|
+
* and sends handshake.accept back.
|
|
2347
|
+
*/
|
|
2348
|
+
async approve(address) {
|
|
2349
|
+
await this._ensureConnected();
|
|
2350
|
+
const pendingList = this._contactBook.getPending();
|
|
2351
|
+
const entry = pendingList.find((p) => p.address === address);
|
|
2352
|
+
if (!entry) {
|
|
2353
|
+
throw new UAMError(`No pending handshake from ${address}`);
|
|
2354
|
+
}
|
|
2355
|
+
const cardDict = JSON.parse(entry.contactCard);
|
|
2356
|
+
const card = contactCardFromDict(cardDict);
|
|
2357
|
+
verifyContactCard(card);
|
|
2358
|
+
const senderVk = deserializeVerifyKey(card.publicKey);
|
|
2359
|
+
bindCardToEnvelope(card, { fromAddress: address }, senderVk);
|
|
2360
|
+
this._contactBook.addContact(card.address, card.publicKey, {
|
|
2361
|
+
displayName: card.displayName,
|
|
2362
|
+
trustState: "trusted",
|
|
2363
|
+
trustSource: "explicit-approval"
|
|
2364
|
+
});
|
|
2365
|
+
this._contactBook.removePending(address);
|
|
2366
|
+
await this._handshake._sendAccept(this, address, senderVk);
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Deny a pending handshake request.
|
|
2370
|
+
*
|
|
2371
|
+
* Removes the entry from pending and sends handshake.deny.
|
|
2372
|
+
*/
|
|
2373
|
+
async deny(address) {
|
|
2374
|
+
await this._ensureConnected();
|
|
2375
|
+
const pendingList = this._contactBook.getPending();
|
|
2376
|
+
const entry = pendingList.find((p) => p.address === address);
|
|
2377
|
+
if (!entry) {
|
|
2378
|
+
throw new UAMError(`No pending handshake from ${address}`);
|
|
2379
|
+
}
|
|
2380
|
+
const cardDict = JSON.parse(entry.contactCard);
|
|
2381
|
+
const card = contactCardFromDict(cardDict);
|
|
2382
|
+
const senderVk = deserializeVerifyKey(card.publicKey);
|
|
2383
|
+
this._contactBook.removePending(address);
|
|
2384
|
+
await this._handshake._sendDeny(this, address, senderVk);
|
|
2385
|
+
}
|
|
2386
|
+
/**
|
|
2387
|
+
* Block an address or domain pattern (HAND-04).
|
|
2388
|
+
*/
|
|
2389
|
+
async block(pattern) {
|
|
2390
|
+
await this._ensureConnected();
|
|
2391
|
+
this._contactBook.addBlock(pattern);
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Remove a block pattern (HAND-04).
|
|
2395
|
+
*/
|
|
2396
|
+
async unblock(pattern) {
|
|
2397
|
+
await this._ensureConnected();
|
|
2398
|
+
this._contactBook.removeBlock(pattern);
|
|
2399
|
+
}
|
|
2400
|
+
// -- Domain verification (DNS-02) ----------------------------------------
|
|
2401
|
+
/**
|
|
2402
|
+
* Verify domain ownership via relay endpoint (DNS-02).
|
|
2403
|
+
*
|
|
2404
|
+
* Polls `POST /api/v1/verify-domain` until the relay confirms
|
|
2405
|
+
* DNS TXT (or HTTPS fallback) verification succeeds, or timeout
|
|
2406
|
+
* elapses.
|
|
2407
|
+
*
|
|
2408
|
+
* Returns `true` if verified, `false` on timeout.
|
|
2409
|
+
*/
|
|
2410
|
+
async verifyDomain(domain, options) {
|
|
2411
|
+
const { generateTxtRecord: generateTxtRecord2 } = await Promise.resolve().then(() => (init_dns_verifier(), dns_verifier_exports));
|
|
2412
|
+
await this._ensureConnected();
|
|
2413
|
+
const timeout = options?.timeout ?? 3e5;
|
|
2414
|
+
const pollInterval = options?.pollInterval ?? 1e4;
|
|
2415
|
+
const expectedTxt = generateTxtRecord2(
|
|
2416
|
+
this.publicKey,
|
|
2417
|
+
this._config.relayUrl
|
|
2418
|
+
);
|
|
2419
|
+
void expectedTxt;
|
|
2420
|
+
const start = Date.now();
|
|
2421
|
+
while (Date.now() - start < timeout) {
|
|
2422
|
+
try {
|
|
2423
|
+
const resp = await fetch(
|
|
2424
|
+
`${this._config.relayUrl}/api/v1/verify-domain`,
|
|
2425
|
+
{
|
|
2426
|
+
method: "POST",
|
|
2427
|
+
headers: {
|
|
2428
|
+
"Content-Type": "application/json",
|
|
2429
|
+
Authorization: `Bearer ${this._token}`
|
|
2430
|
+
},
|
|
2431
|
+
body: JSON.stringify({ domain }),
|
|
2432
|
+
signal: AbortSignal.timeout(3e4)
|
|
2433
|
+
}
|
|
2434
|
+
);
|
|
2435
|
+
if (resp.ok) {
|
|
2436
|
+
const result = await resp.json();
|
|
2437
|
+
if (result["status"] === "verified") {
|
|
2438
|
+
return true;
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
} catch {
|
|
2442
|
+
}
|
|
2443
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
2444
|
+
}
|
|
2445
|
+
return false;
|
|
2446
|
+
}
|
|
2447
|
+
// -- Internal methods ----------------------------------------------------
|
|
2448
|
+
/** Multi-relay failover (CARD-06). */
|
|
2449
|
+
async _getRelayUrls(toAddress) {
|
|
2450
|
+
const urls = this._contactBook.getRelayUrls(toAddress);
|
|
2451
|
+
if (urls !== null) {
|
|
2452
|
+
return urls;
|
|
2453
|
+
}
|
|
2454
|
+
return [this._config.relayUrl];
|
|
2455
|
+
}
|
|
2456
|
+
/**
|
|
2457
|
+
* Try sending wire envelope to each relay URL in order (CARD-06).
|
|
2458
|
+
*
|
|
2459
|
+
* Uses transient fetch POST requests so the envelope can be delivered
|
|
2460
|
+
* to any relay that hosts the recipient.
|
|
2461
|
+
*/
|
|
2462
|
+
async _trySendWithFailover(wire, relayUrls) {
|
|
2463
|
+
let lastError = null;
|
|
2464
|
+
for (const url of relayUrls) {
|
|
2465
|
+
let base = url.replace(/\/+$/, "");
|
|
2466
|
+
if (base.endsWith("/ws")) {
|
|
2467
|
+
base = base.slice(0, -3);
|
|
2468
|
+
}
|
|
2469
|
+
base = base.replace("wss://", "https://").replace("ws://", "http://");
|
|
2470
|
+
const sendUrl = `${base}/api/v1/send`;
|
|
2471
|
+
try {
|
|
2472
|
+
const resp = await fetch(sendUrl, {
|
|
2473
|
+
method: "POST",
|
|
2474
|
+
headers: {
|
|
2475
|
+
"Content-Type": "application/json",
|
|
2476
|
+
Authorization: `Bearer ${this._token}`
|
|
2477
|
+
},
|
|
2478
|
+
body: JSON.stringify({ envelope: wire }),
|
|
2479
|
+
signal: AbortSignal.timeout(1e4)
|
|
2480
|
+
});
|
|
2481
|
+
if (!resp.ok) {
|
|
2482
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
2483
|
+
}
|
|
2484
|
+
return;
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
if (lastError !== null) {
|
|
2490
|
+
throw lastError;
|
|
2491
|
+
}
|
|
2492
|
+
throw new UAMError("No relay URLs to try");
|
|
2493
|
+
}
|
|
2494
|
+
async _ensureConnected() {
|
|
2495
|
+
if (!this._connected) {
|
|
2496
|
+
await this.connect();
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Resolve a recipient's public key, checking contact book first.
|
|
2501
|
+
*
|
|
2502
|
+
* Pure lookup: never writes to the contact book. The contact row is
|
|
2503
|
+
* created by _initiateHandshake (as "handshake-sent") once the request
|
|
2504
|
+
* has gone out, giving the lifecycle no row -> handshake-sent -> pinned.
|
|
2505
|
+
*
|
|
2506
|
+
* TOFU-01: Known contacts always use stored key (zero network I/O).
|
|
2507
|
+
* TOFU-03: Race-condition double-check after network resolve.
|
|
2508
|
+
* Raises KeyPinningError if a pinned contact's key doesn't match.
|
|
2509
|
+
*/
|
|
2510
|
+
async _resolvePublicKey(toAddress) {
|
|
2511
|
+
const pkStr = this._contactBook.getPublicKey(toAddress);
|
|
2512
|
+
if (pkStr !== null) {
|
|
2513
|
+
return deserializeVerifyKey(pkStr);
|
|
2514
|
+
}
|
|
2515
|
+
const resolvedPkStr = await this._resolver.resolvePublicKey(
|
|
2516
|
+
toAddress,
|
|
2517
|
+
this._token,
|
|
2518
|
+
this._config.relayUrl
|
|
2519
|
+
);
|
|
2520
|
+
const storedPk = this._contactBook.getPublicKey(toAddress);
|
|
2521
|
+
if (storedPk !== null && storedPk !== resolvedPkStr) {
|
|
2522
|
+
const trust = this._contactBook.getTrustState(toAddress);
|
|
2523
|
+
if (trust === "pinned" || trust === "trusted" || trust === "verified") {
|
|
2524
|
+
throw new KeyPinningError(
|
|
2525
|
+
`CRITICAL: Public key mismatch for ${toAddress}! Stored key does not match resolved key. This may indicate a relay MITM attack. To accept the new key: uam contact remove ${toAddress}`
|
|
2526
|
+
);
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
return deserializeVerifyKey(resolvedPkStr);
|
|
2530
|
+
}
|
|
2531
|
+
async _initiateHandshake(toAddress, recipientVk) {
|
|
2532
|
+
const wire = await this._handshake.createHandshakeRequest(
|
|
2533
|
+
this,
|
|
2534
|
+
toAddress,
|
|
2535
|
+
recipientVk
|
|
2536
|
+
);
|
|
2537
|
+
await this._transport.send(wire);
|
|
2538
|
+
this._contactBook.addContact(
|
|
2539
|
+
toAddress,
|
|
2540
|
+
serializeVerifyKey(recipientVk),
|
|
2541
|
+
{ trustState: "handshake-sent" }
|
|
2542
|
+
);
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* Process a single inbound envelope: verify, decrypt, handle handshakes.
|
|
2546
|
+
*
|
|
2547
|
+
* Returns null for invalid messages or handshake protocol messages.
|
|
2548
|
+
*/
|
|
2549
|
+
async _processInbound(raw) {
|
|
2550
|
+
const envelope = fromWireDict(raw);
|
|
2551
|
+
if (this._contactBook.isBlocked(envelope.fromAddress)) {
|
|
2552
|
+
return null;
|
|
2553
|
+
}
|
|
2554
|
+
let senderPkStr = this._contactBook.getPublicKey(envelope.fromAddress);
|
|
2555
|
+
if (senderPkStr === null) {
|
|
2556
|
+
try {
|
|
2557
|
+
senderPkStr = await this._resolver.resolvePublicKey(
|
|
2558
|
+
envelope.fromAddress,
|
|
2559
|
+
this._token,
|
|
2560
|
+
this._config.relayUrl
|
|
2561
|
+
);
|
|
2562
|
+
} catch {
|
|
2563
|
+
return null;
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
const senderVk = deserializeVerifyKey(senderPkStr);
|
|
2567
|
+
try {
|
|
2568
|
+
verifyEnvelope(envelope, senderVk);
|
|
2569
|
+
} catch (err) {
|
|
2570
|
+
if (err instanceof SignatureVerificationError) {
|
|
2571
|
+
return null;
|
|
2572
|
+
}
|
|
2573
|
+
throw err;
|
|
2574
|
+
}
|
|
2575
|
+
if (!await this._passesInboundChecks(envelope, raw)) {
|
|
2576
|
+
return null;
|
|
2577
|
+
}
|
|
2578
|
+
if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */ || envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */ || envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
|
|
2579
|
+
return this._handshake.handleInbound(this, envelope, senderVk);
|
|
2580
|
+
}
|
|
2581
|
+
if (this._handshake._trustPolicy !== "auto-accept") {
|
|
2582
|
+
if (!this._contactBook.isTrustedOrVerified(envelope.fromAddress)) {
|
|
2583
|
+
return null;
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
let plaintextBytes;
|
|
2587
|
+
try {
|
|
2588
|
+
plaintextBytes = decryptPayload(
|
|
2589
|
+
envelope.payload,
|
|
2590
|
+
this._keyManager.signingKey,
|
|
2591
|
+
senderVk
|
|
2592
|
+
);
|
|
2593
|
+
} catch (err) {
|
|
2594
|
+
if (err instanceof DecryptionError) {
|
|
2595
|
+
return null;
|
|
2596
|
+
}
|
|
2597
|
+
throw err;
|
|
2598
|
+
}
|
|
2599
|
+
return Object.freeze({
|
|
2600
|
+
messageId: envelope.messageId,
|
|
2601
|
+
fromAddress: envelope.fromAddress,
|
|
2602
|
+
toAddress: envelope.toAddress,
|
|
2603
|
+
content: new TextDecoder().decode(plaintextBytes),
|
|
2604
|
+
timestamp: envelope.timestamp,
|
|
2605
|
+
type: envelope.type,
|
|
2606
|
+
threadId: envelope.threadId,
|
|
2607
|
+
replyTo: envelope.replyTo,
|
|
2608
|
+
mediaType: envelope.mediaType,
|
|
2609
|
+
verified: true
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Recipient, freshness, expiry and replay checks for a signature-verified
|
|
2614
|
+
* inbound envelope. Returns false (and logs) when it must be dropped.
|
|
2615
|
+
* Matches the Python SDK's _process_inbound checks.
|
|
2616
|
+
*/
|
|
2617
|
+
async _passesInboundChecks(envelope, raw) {
|
|
2618
|
+
const id = envelope.messageId;
|
|
2619
|
+
const from = envelope.fromAddress;
|
|
2620
|
+
if (this._address !== null && envelope.toAddress !== this._address) {
|
|
2621
|
+
log.warn(
|
|
2622
|
+
`Dropped envelope ${id} from ${from} addressed to ${envelope.toAddress} (we are ${this._address})`
|
|
2623
|
+
);
|
|
2624
|
+
return false;
|
|
2625
|
+
}
|
|
2626
|
+
const now = this._clock();
|
|
2627
|
+
const ts = Date.parse(envelope.timestamp);
|
|
2628
|
+
if (Number.isNaN(ts)) {
|
|
2629
|
+
log.warn(
|
|
2630
|
+
`Dropped envelope ${id} from ${from} with malformed timestamp ` + JSON.stringify(envelope.timestamp)
|
|
2631
|
+
);
|
|
2632
|
+
return false;
|
|
2633
|
+
}
|
|
2634
|
+
const ageSeconds = Math.abs(now - ts) / 1e3;
|
|
2635
|
+
if (ageSeconds > MAX_ENVELOPE_AGE && !await this._wasStored(raw, envelope.toAddress, ts, now)) {
|
|
2636
|
+
log.info(
|
|
2637
|
+
`Dropped stale envelope ${id} from ${from} (age=${Math.round(ageSeconds)}s > ${MAX_ENVELOPE_AGE}s)`
|
|
2638
|
+
);
|
|
2639
|
+
return false;
|
|
2640
|
+
}
|
|
2641
|
+
if (envelope.expires !== void 0 && envelope.expires !== null) {
|
|
2642
|
+
const exp = Date.parse(envelope.expires);
|
|
2643
|
+
if (Number.isNaN(exp)) {
|
|
2644
|
+
log.warn(
|
|
2645
|
+
`Dropped envelope ${id} from ${from} with malformed expires ` + JSON.stringify(envelope.expires)
|
|
2646
|
+
);
|
|
2647
|
+
return false;
|
|
2648
|
+
}
|
|
2649
|
+
if (now > exp) {
|
|
2650
|
+
log.info(
|
|
2651
|
+
`Dropped expired envelope ${id} from ${from} (expires=${envelope.expires})`
|
|
2652
|
+
);
|
|
2653
|
+
return false;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
if (await this._replayCache.seenOrRecord(from, id, ts)) {
|
|
2657
|
+
log.info(`Dropped replayed envelope ${id} from ${from}`);
|
|
2658
|
+
return false;
|
|
2659
|
+
}
|
|
2660
|
+
return true;
|
|
2661
|
+
}
|
|
2662
|
+
/** True if a message too old to be live is stored mail, not a replay. */
|
|
2663
|
+
async _wasStored(raw, toAddress, signedAt, now) {
|
|
2664
|
+
const relayDomain = toAddress.split("::").pop();
|
|
2665
|
+
if (!relayVouches(raw, signedAt, now, relayDomain)) return false;
|
|
2666
|
+
if (await this._replayCache.signedBeforeCache(signedAt)) return false;
|
|
2667
|
+
return this._relayStampsMail();
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* True if our relay writes relay_meta, so a sender can't have.
|
|
2671
|
+
*
|
|
2672
|
+
* Asked only when a stale message carries a stamp. A yes is remembered for
|
|
2673
|
+
* good, a no for RELAY_NO_MEMORY_MS: a relay can be upgraded while this
|
|
2674
|
+
* agent runs. A failed lookup isn't remembered, because the relay has
|
|
2675
|
+
* already marked this mail delivered and the next message should get a
|
|
2676
|
+
* real answer.
|
|
2677
|
+
*/
|
|
2678
|
+
async _relayStampsMail() {
|
|
2679
|
+
if (this._relayStamps) return true;
|
|
2680
|
+
if (this._monotonic() < this._relayNoUntil) return false;
|
|
2681
|
+
try {
|
|
2682
|
+
const features = await this._fetchRelayFeatures();
|
|
2683
|
+
this._relayStamps = features.includes(RELAY_META_FEATURE);
|
|
2684
|
+
if (!this._relayStamps) {
|
|
2685
|
+
this._relayNoUntil = this._monotonic() + RELAY_NO_MEMORY_MS;
|
|
2686
|
+
}
|
|
2687
|
+
} catch (err) {
|
|
2688
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2689
|
+
log.warn(`Cannot read relay features: ${reason}`);
|
|
2690
|
+
}
|
|
2691
|
+
return this._relayStamps;
|
|
2692
|
+
}
|
|
2693
|
+
/** The relay's features; a body that doesn't list any is an empty list. */
|
|
2694
|
+
async _fetchRelayFeatures() {
|
|
2695
|
+
const resp = await fetch(`${this._config.relayUrl}/api/v1/versions`, {
|
|
2696
|
+
signal: AbortSignal.timeout(1e4)
|
|
2697
|
+
});
|
|
2698
|
+
if (!resp.ok) throw new UAMError(`Relay answered ${resp.status}`);
|
|
2699
|
+
const body = await resp.text();
|
|
2700
|
+
let data;
|
|
2701
|
+
try {
|
|
2702
|
+
data = JSON.parse(body);
|
|
2703
|
+
} catch {
|
|
2704
|
+
return [];
|
|
2705
|
+
}
|
|
2706
|
+
return Array.isArray(data?.features) ? data.features : [];
|
|
2707
|
+
}
|
|
2708
|
+
/**
|
|
2709
|
+
* Sweep expired pending handshakes and send receipt.failed (HAND-03).
|
|
2710
|
+
*/
|
|
2711
|
+
async _sweepExpiredHandshakes() {
|
|
2712
|
+
const expired = this._contactBook.getExpiredPending(7);
|
|
2713
|
+
for (const entry of expired) {
|
|
2714
|
+
try {
|
|
2715
|
+
const cardDict = JSON.parse(entry.contactCard);
|
|
2716
|
+
const card = contactCardFromDict(cardDict);
|
|
2717
|
+
const recipientVk = deserializeVerifyKey(card.publicKey);
|
|
2718
|
+
const failPayload = JSON.stringify({
|
|
2719
|
+
reason: "handshake_expired",
|
|
2720
|
+
original_from: entry.address
|
|
2721
|
+
});
|
|
2722
|
+
const envelope = await createEnvelope(
|
|
2723
|
+
this._address,
|
|
2724
|
+
entry.address,
|
|
2725
|
+
"receipt.failed" /* RECEIPT_FAILED */,
|
|
2726
|
+
new TextEncoder().encode(failPayload),
|
|
2727
|
+
this._keyManager.signingKey,
|
|
2728
|
+
recipientVk
|
|
2729
|
+
);
|
|
2730
|
+
const wire = toWireDict(envelope);
|
|
2731
|
+
await this._transport.send(wire);
|
|
2732
|
+
} catch {
|
|
2733
|
+
}
|
|
2734
|
+
this._contactBook.removePending(entry.address);
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Send receipt.read back to the sender (RCPT-01).
|
|
2739
|
+
*
|
|
2740
|
+
* Fire-and-forget: errors are never propagated.
|
|
2741
|
+
* Anti-loop guard: receipts, handshakes, and session messages are skipped.
|
|
2742
|
+
*/
|
|
2743
|
+
async _sendReadReceipt(msg) {
|
|
2744
|
+
if (msg.type.startsWith("receipt.") || msg.type.startsWith("handshake.") || msg.type.startsWith("session.")) {
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
try {
|
|
2748
|
+
const senderPkStr = this._contactBook.getPublicKey(msg.fromAddress);
|
|
2749
|
+
if (senderPkStr === null) return;
|
|
2750
|
+
const senderVk = deserializeVerifyKey(senderPkStr);
|
|
2751
|
+
const receiptPayload = JSON.stringify({
|
|
2752
|
+
message_id: msg.messageId
|
|
2753
|
+
});
|
|
2754
|
+
const envelope = await createEnvelope(
|
|
2755
|
+
this._address,
|
|
2756
|
+
msg.fromAddress,
|
|
2757
|
+
"receipt.read" /* RECEIPT_READ */,
|
|
2758
|
+
new TextEncoder().encode(receiptPayload),
|
|
2759
|
+
this._keyManager.signingKey,
|
|
2760
|
+
senderVk,
|
|
2761
|
+
{ replyTo: msg.messageId }
|
|
2762
|
+
);
|
|
2763
|
+
const wire = toWireDict(envelope);
|
|
2764
|
+
await this._transport.send(wire);
|
|
2765
|
+
} catch {
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
/**
|
|
2769
|
+
* Register with the relay server.
|
|
2770
|
+
*
|
|
2771
|
+
* Calls POST /api/v1/register with agent name and public key. If the
|
|
2772
|
+
* address exists (409), repeats it with a proof that this agent holds the
|
|
2773
|
+
* key. Stores the returned API key on disk.
|
|
2774
|
+
*/
|
|
2775
|
+
async _registerWithRelay() {
|
|
2776
|
+
const body = {
|
|
2777
|
+
agent_name: this._config.name,
|
|
2778
|
+
public_key: serializeVerifyKey(this._keyManager.verifyKey)
|
|
2779
|
+
};
|
|
2780
|
+
let resp = await this._postRegistration(body);
|
|
2781
|
+
if (resp.status === 409) {
|
|
2782
|
+
const address2 = await this._addressOnRelay();
|
|
2783
|
+
resp = await this._postRegistration({
|
|
2784
|
+
...body,
|
|
2785
|
+
proof: this._registrationProof(address2)
|
|
2786
|
+
});
|
|
2787
|
+
if (resp.status === 409 || resp.status === 403) {
|
|
2788
|
+
throw this._ownershipNotProven(address2);
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
if (!resp.ok) {
|
|
2792
|
+
const text = await resp.text();
|
|
2793
|
+
throw new UAMError(
|
|
2794
|
+
`Registration failed: ${resp.status} ${text}`
|
|
2795
|
+
);
|
|
2796
|
+
}
|
|
2797
|
+
const data = await resp.json();
|
|
2798
|
+
const address = data["address"];
|
|
2799
|
+
this._useAddress(address);
|
|
2800
|
+
this._token = data["token"];
|
|
2801
|
+
const { name } = this._config;
|
|
2802
|
+
const token = this._token;
|
|
2803
|
+
this._tryToSave("token", () => this._keyManager.saveToken(name, token));
|
|
2804
|
+
this._saveAddress(address);
|
|
2805
|
+
}
|
|
2806
|
+
/** This agent's address on the relay, for signing a proof. */
|
|
2807
|
+
async _addressOnRelay() {
|
|
2808
|
+
const { name, explicitRelayDomain, relayDomain, relayUrl } = this._config;
|
|
2809
|
+
if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
|
|
2810
|
+
const domain = await this._fetchRelayDomain();
|
|
2811
|
+
if (domain === null) return `${name}::${relayDomain}`;
|
|
2812
|
+
if (!this._relayMayUse(domain)) {
|
|
2813
|
+
throw new UAMError(
|
|
2814
|
+
`The relay at ${relayUrl} says its domain is ${domain}. Pass the domain option as ${JSON.stringify(domain)} if that is right.`
|
|
2815
|
+
);
|
|
2816
|
+
}
|
|
2817
|
+
return `${name}::${domain}`;
|
|
2818
|
+
}
|
|
2819
|
+
/**
|
|
2820
|
+
* True if the relay's URL fits `domain`, so a proof can be signed for it.
|
|
2821
|
+
*
|
|
2822
|
+
* A proof names an address on one relay, so it is only signed for a domain
|
|
2823
|
+
* this URL belongs to. A relay on this machine may use any domain.
|
|
2824
|
+
*/
|
|
2825
|
+
_relayMayUse(domain) {
|
|
2826
|
+
let host;
|
|
2827
|
+
try {
|
|
2828
|
+
host = new URL(this._config.relayUrl).hostname.toLowerCase();
|
|
2829
|
+
} catch {
|
|
2830
|
+
return false;
|
|
2831
|
+
}
|
|
2832
|
+
const wanted = domain.toLowerCase();
|
|
2833
|
+
return host === wanted || host.endsWith(`.${wanted}`) || host === "localhost" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
|
|
2834
|
+
}
|
|
2835
|
+
_postRegistration(body) {
|
|
2836
|
+
return fetch(`${this._config.relayUrl}/api/v1/register`, {
|
|
2837
|
+
method: "POST",
|
|
2838
|
+
headers: { "Content-Type": "application/json" },
|
|
2839
|
+
body: JSON.stringify(body)
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
/**
|
|
2843
|
+
* Sign a statement that this agent holds the key for `address`.
|
|
2844
|
+
*
|
|
2845
|
+
* The signed string must stay byte-identical to the Python SDK's.
|
|
2846
|
+
*/
|
|
2847
|
+
_registrationProof(address) {
|
|
2848
|
+
const timestamp = utcTimestamp();
|
|
2849
|
+
const signature = signMessage(
|
|
2850
|
+
new TextEncoder().encode(`uam-register-v1
|
|
2851
|
+
${address}
|
|
2852
|
+
${timestamp}`),
|
|
2853
|
+
this._keyManager.signingKey
|
|
2854
|
+
);
|
|
2855
|
+
return { timestamp, signature };
|
|
2856
|
+
}
|
|
2857
|
+
_ownershipNotProven(address) {
|
|
2858
|
+
return new UAMError(
|
|
2859
|
+
`Address already registered, and this key could not prove ownership: ` + address
|
|
2860
|
+
);
|
|
2861
|
+
}
|
|
2862
|
+
/** Remember `address` for this relay, if the key folder allows it. */
|
|
2863
|
+
_saveAddress(address) {
|
|
2864
|
+
const { name, relayUrl } = this._config;
|
|
2865
|
+
this._tryToSave(
|
|
2866
|
+
"address",
|
|
2867
|
+
() => this._keyManager.saveAddress(name, address, relayUrl)
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
/** Saving is best effort: a key folder we can't write doesn't stop us. */
|
|
2871
|
+
_tryToSave(what, save) {
|
|
2872
|
+
try {
|
|
2873
|
+
save();
|
|
2874
|
+
} catch (err) {
|
|
2875
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2876
|
+
log.warn(`Could not save the ${what}: ${reason}`);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
/** Take `address` as ours; its domain is this relay's real domain. */
|
|
2880
|
+
_useAddress(address) {
|
|
2881
|
+
this._address = address;
|
|
2882
|
+
const domain = parseAddress(address).domain;
|
|
2883
|
+
this._config.relayDomain = domain;
|
|
2884
|
+
if (this._resolver instanceof SmartResolver) {
|
|
2885
|
+
this._resolver.relayDomain = domain;
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
/**
|
|
2889
|
+
* The address the stored token belongs to.
|
|
2890
|
+
*
|
|
2891
|
+
* An explicit domain settles it. Otherwise the relay chooses the domain,
|
|
2892
|
+
* and it need not be the relay's hostname: use the address saved for this
|
|
2893
|
+
* relay, or ask the relay once and keep the answer.
|
|
2894
|
+
*/
|
|
2895
|
+
async _storedAddress() {
|
|
2896
|
+
const { name, explicitRelayDomain } = this._config;
|
|
2897
|
+
if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
|
|
2898
|
+
const saved = this._keyManager.loadAddress(name, this._config.relayUrl);
|
|
2899
|
+
if (saved) return saved;
|
|
2900
|
+
const domain = await this._fetchRelayDomain();
|
|
2901
|
+
if (domain === null) return `${name}::${this._config.relayDomain}`;
|
|
2902
|
+
const address = `${name}::${domain}`;
|
|
2903
|
+
this._saveAddress(address);
|
|
2904
|
+
return address;
|
|
2905
|
+
}
|
|
2906
|
+
/** Ask the relay for its domain; null if it cannot say. */
|
|
2907
|
+
async _fetchRelayDomain() {
|
|
2908
|
+
const url = `${this._config.relayUrl}/.well-known/uam-relay.json`;
|
|
2909
|
+
try {
|
|
2910
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(1e4) });
|
|
2911
|
+
if (!resp.ok) return null;
|
|
2912
|
+
const data = await resp.json();
|
|
2913
|
+
const domain = data["relay_domain"];
|
|
2914
|
+
if (typeof domain !== "string" || domain === "") return null;
|
|
2915
|
+
return parseAddress(`${this._config.name}::${domain}`).domain;
|
|
2916
|
+
} catch {
|
|
2917
|
+
return null;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
};
|
|
2921
|
+
|
|
2922
|
+
// src/sdk/webhook-verify.ts
|
|
2923
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
2924
|
+
function verifyWebhookSignature(payload, signatureHeader, secret) {
|
|
2925
|
+
if (!signatureHeader.startsWith("sha256=")) {
|
|
2926
|
+
return false;
|
|
2927
|
+
}
|
|
2928
|
+
const expected = createHmac("sha256", secret).update(payload).digest("hex");
|
|
2929
|
+
const received = signatureHeader.slice("sha256=".length);
|
|
2930
|
+
const expectedBuf = Buffer.from(expected, "utf-8");
|
|
2931
|
+
const receivedBuf = Buffer.from(received, "utf-8");
|
|
2932
|
+
if (expectedBuf.length !== receivedBuf.length) {
|
|
2933
|
+
return false;
|
|
2934
|
+
}
|
|
2935
|
+
return timingSafeEqual(expectedBuf, receivedBuf);
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
// src/sdk/index.ts
|
|
2939
|
+
init_dns_verifier();
|
|
2940
|
+
export {
|
|
2941
|
+
Agent,
|
|
2942
|
+
ContactBook,
|
|
2943
|
+
protocol_exports as protocol,
|
|
2944
|
+
verifyWebhookSignature
|
|
2945
|
+
};
|
|
2946
|
+
//# sourceMappingURL=index.js.map
|