@youam/sdk 0.2.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/dist/cli/index.cjs +777 -65
- package/dist/index.cjs +781 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +217 -14
- package/dist/index.d.ts +217 -14
- package/dist/index.js +778 -50
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
package/dist/index.js
CHANGED
|
@@ -88,6 +88,9 @@ var init_dns_verifier = __esm({
|
|
|
88
88
|
}
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
// src/sdk/agent.ts
|
|
92
|
+
import { join as join4 } from "path";
|
|
93
|
+
|
|
91
94
|
// src/protocol/index.ts
|
|
92
95
|
var protocol_exports = {};
|
|
93
96
|
__export(protocol_exports, {
|
|
@@ -98,6 +101,7 @@ __export(protocol_exports, {
|
|
|
98
101
|
InvalidContactCardError: () => InvalidContactCardError,
|
|
99
102
|
InvalidEnvelopeError: () => InvalidEnvelopeError,
|
|
100
103
|
KeyPinningError: () => KeyPinningError,
|
|
104
|
+
MAX_ENVELOPE_AGE: () => MAX_ENVELOPE_AGE,
|
|
101
105
|
MAX_ENVELOPE_SIZE: () => MAX_ENVELOPE_SIZE,
|
|
102
106
|
MessageType: () => MessageType,
|
|
103
107
|
SignatureError: () => SignatureError,
|
|
@@ -137,6 +141,7 @@ __export(protocol_exports, {
|
|
|
137
141
|
// src/protocol/types.ts
|
|
138
142
|
var UAM_VERSION = "0.1";
|
|
139
143
|
var MAX_ENVELOPE_SIZE = 65536;
|
|
144
|
+
var MAX_ENVELOPE_AGE = 300;
|
|
140
145
|
var MessageType = /* @__PURE__ */ ((MessageType2) => {
|
|
141
146
|
MessageType2["MESSAGE"] = "message";
|
|
142
147
|
MessageType2["HANDSHAKE_REQUEST"] = "handshake.request";
|
|
@@ -661,10 +666,16 @@ function verifyContactCard(card) {
|
|
|
661
666
|
verifySignature(canonicalize(signable), card.signature, vk);
|
|
662
667
|
}
|
|
663
668
|
|
|
669
|
+
// src/version.ts
|
|
670
|
+
var SDK_VERSION = "0.2.1";
|
|
671
|
+
|
|
664
672
|
// src/sdk/config.ts
|
|
665
673
|
import { homedir } from "os";
|
|
666
674
|
import { join } from "path";
|
|
667
675
|
var DEFAULT_RELAY_URL = "https://relay.youam.network";
|
|
676
|
+
function trimRelayUrl(url) {
|
|
677
|
+
return url.replace(/\/+$/, "");
|
|
678
|
+
}
|
|
668
679
|
var VALID_POLICIES = /* @__PURE__ */ new Set([
|
|
669
680
|
"auto-accept",
|
|
670
681
|
"approval-required",
|
|
@@ -681,9 +692,13 @@ var SDKConfig = class {
|
|
|
681
692
|
transportType;
|
|
682
693
|
trustPolicy;
|
|
683
694
|
relayDomain;
|
|
695
|
+
/** The domain the caller chose, if any; null when only derived. */
|
|
696
|
+
explicitRelayDomain;
|
|
684
697
|
constructor(options) {
|
|
685
698
|
this.name = options.name;
|
|
686
|
-
this.relayUrl =
|
|
699
|
+
this.relayUrl = trimRelayUrl(
|
|
700
|
+
options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL
|
|
701
|
+
);
|
|
687
702
|
if (options.relayWsUrl) {
|
|
688
703
|
this.relayWsUrl = options.relayWsUrl;
|
|
689
704
|
} else {
|
|
@@ -694,6 +709,7 @@ var SDKConfig = class {
|
|
|
694
709
|
this.relayWsUrl = wsUrl;
|
|
695
710
|
}
|
|
696
711
|
const envDomain = process.env["UAM_RELAY_DOMAIN"];
|
|
712
|
+
this.explicitRelayDomain = envDomain || options.relayDomain || null;
|
|
697
713
|
if (envDomain) {
|
|
698
714
|
this.relayDomain = envDomain;
|
|
699
715
|
} else if (options.relayDomain) {
|
|
@@ -727,6 +743,27 @@ var SDKConfig = class {
|
|
|
727
743
|
}
|
|
728
744
|
};
|
|
729
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
|
+
|
|
730
767
|
// src/sdk/key-manager.ts
|
|
731
768
|
import {
|
|
732
769
|
chmodSync,
|
|
@@ -803,17 +840,54 @@ var KeyManager = class {
|
|
|
803
840
|
writeFileSync(tokenPath, token);
|
|
804
841
|
this._setPermissions(tokenPath);
|
|
805
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
|
+
}
|
|
806
875
|
/**
|
|
807
876
|
* Load a previously saved token, or return null.
|
|
808
877
|
*
|
|
809
878
|
* Checks UAM_TOKEN env var first, then file-based storage,
|
|
810
|
-
* then legacy .api_key files for backward compatibility.
|
|
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.
|
|
811
882
|
*/
|
|
812
|
-
loadToken(name) {
|
|
883
|
+
loadToken(name, relayUrl) {
|
|
813
884
|
const envToken = process.env["UAM_TOKEN"];
|
|
814
885
|
if (envToken) {
|
|
815
886
|
return envToken.trim();
|
|
816
887
|
}
|
|
888
|
+
if (relayUrl !== void 0 && this._savedForAnotherRelay(name, relayUrl)) {
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
817
891
|
const tokenPath = join2(this._keyDir, `${name}.token`);
|
|
818
892
|
if (existsSync(tokenPath)) {
|
|
819
893
|
return readFileSync(tokenPath, "utf-8").trim();
|
|
@@ -824,6 +898,20 @@ var KeyManager = class {
|
|
|
824
898
|
}
|
|
825
899
|
return null;
|
|
826
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
|
+
}
|
|
827
915
|
/** Set file permissions to 0o600 (owner read/write only). */
|
|
828
916
|
_setPermissions(path) {
|
|
829
917
|
if (platform() !== "win32") {
|
|
@@ -851,6 +939,20 @@ var KeyManager = class {
|
|
|
851
939
|
import { mkdirSync as mkdirSync2 } from "fs";
|
|
852
940
|
import { join as join3, dirname } from "path";
|
|
853
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
|
|
854
956
|
var ContactBook = class {
|
|
855
957
|
_dbPath;
|
|
856
958
|
_db = null;
|
|
@@ -971,11 +1073,30 @@ var ContactBook = class {
|
|
|
971
1073
|
}
|
|
972
1074
|
/**
|
|
973
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`.
|
|
974
1084
|
*/
|
|
975
1085
|
addContact(address, publicKey, options) {
|
|
976
1086
|
if (this._db === null) {
|
|
977
1087
|
throw new Error("ContactBook not open. Call open() first.");
|
|
978
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
|
+
}
|
|
979
1100
|
const displayName = options?.displayName ?? null;
|
|
980
1101
|
const trustState = options?.trustState ?? "trusted";
|
|
981
1102
|
const trustSource = options?.trustSource ?? null;
|
|
@@ -1148,6 +1269,18 @@ var ContactBook = class {
|
|
|
1148
1269
|
};
|
|
1149
1270
|
|
|
1150
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
|
+
}
|
|
1151
1284
|
var HandshakeManager = class {
|
|
1152
1285
|
_contactBook;
|
|
1153
1286
|
_trustPolicy;
|
|
@@ -1190,7 +1323,7 @@ var HandshakeManager = class {
|
|
|
1190
1323
|
if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */) {
|
|
1191
1324
|
await this._handleRequest(agent, envelope, senderVk);
|
|
1192
1325
|
} else if (envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */) {
|
|
1193
|
-
await this._handleAccept(envelope, senderVk);
|
|
1326
|
+
await this._handleAccept(agent, envelope, senderVk);
|
|
1194
1327
|
} else if (envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
|
|
1195
1328
|
}
|
|
1196
1329
|
return null;
|
|
@@ -1208,6 +1341,7 @@ var HandshakeManager = class {
|
|
|
1208
1341
|
);
|
|
1209
1342
|
const card = contactCardFromDict(cardDict);
|
|
1210
1343
|
verifyContactCard(card);
|
|
1344
|
+
bindCardToEnvelope(card, envelope, senderVk);
|
|
1211
1345
|
if (this._trustPolicy === "auto-accept") {
|
|
1212
1346
|
this._contactBook.addContact(card.address, card.publicKey, {
|
|
1213
1347
|
displayName: card.displayName,
|
|
@@ -1226,8 +1360,46 @@ var HandshakeManager = class {
|
|
|
1226
1360
|
}
|
|
1227
1361
|
/**
|
|
1228
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).
|
|
1229
1373
|
*/
|
|
1230
|
-
async _handleAccept(envelope, senderVk) {
|
|
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
|
+
}
|
|
1231
1403
|
const senderPkStr = serializeVerifyKey(senderVk);
|
|
1232
1404
|
this._contactBook.addContact(envelope.fromAddress, senderPkStr, {
|
|
1233
1405
|
trustState: "pinned"
|
|
@@ -1284,6 +1456,10 @@ var HandshakeManager = class {
|
|
|
1284
1456
|
}
|
|
1285
1457
|
};
|
|
1286
1458
|
|
|
1459
|
+
// src/sdk/resolver-base.ts
|
|
1460
|
+
var AddressResolver = class {
|
|
1461
|
+
};
|
|
1462
|
+
|
|
1287
1463
|
// src/sdk/tier3.ts
|
|
1288
1464
|
import { createPublicClient, http, defineChain } from "viem";
|
|
1289
1465
|
var DEFAULT_RPC_URL = "https://sepolia.base.org";
|
|
@@ -1400,8 +1576,6 @@ var Tier3Resolver = class extends AddressResolver {
|
|
|
1400
1576
|
|
|
1401
1577
|
// src/sdk/resolver.ts
|
|
1402
1578
|
init_dns_verifier();
|
|
1403
|
-
var AddressResolver = class {
|
|
1404
|
-
};
|
|
1405
1579
|
var Tier1Resolver = class extends AddressResolver {
|
|
1406
1580
|
async resolvePublicKey(address, _token, relayUrl) {
|
|
1407
1581
|
const resp = await fetch(
|
|
@@ -1438,13 +1612,13 @@ var Tier2Resolver = class extends AddressResolver {
|
|
|
1438
1612
|
}
|
|
1439
1613
|
};
|
|
1440
1614
|
var SmartResolver = class extends AddressResolver {
|
|
1441
|
-
|
|
1615
|
+
relayDomain;
|
|
1442
1616
|
_tier1;
|
|
1443
1617
|
_tier2;
|
|
1444
1618
|
_tier3;
|
|
1445
1619
|
constructor(relayDomain, tier3Config) {
|
|
1446
1620
|
super();
|
|
1447
|
-
this.
|
|
1621
|
+
this.relayDomain = relayDomain;
|
|
1448
1622
|
this._tier1 = new Tier1Resolver();
|
|
1449
1623
|
this._tier2 = new Tier2Resolver();
|
|
1450
1624
|
this._tier3 = tier3Config ? new Tier3Resolver(tier3Config) : null;
|
|
@@ -1452,7 +1626,7 @@ var SmartResolver = class extends AddressResolver {
|
|
|
1452
1626
|
async resolvePublicKey(address, token, relayUrl) {
|
|
1453
1627
|
const parsed = parseAddress(address);
|
|
1454
1628
|
const domain = parsed.domain;
|
|
1455
|
-
if (domain === this.
|
|
1629
|
+
if (domain === this.relayDomain) {
|
|
1456
1630
|
return this._tier1.resolvePublicKey(address, token, relayUrl);
|
|
1457
1631
|
}
|
|
1458
1632
|
if (domain.includes(".")) {
|
|
@@ -1531,8 +1705,10 @@ import WebSocket from "ws";
|
|
|
1531
1705
|
var BASE_DELAY = 1;
|
|
1532
1706
|
var MAX_DELAY = 60;
|
|
1533
1707
|
var JITTER_RANGE = 1;
|
|
1708
|
+
var SEND_SETTLE_SECONDS = 5;
|
|
1534
1709
|
var WebSocketTransport = class extends TransportBase {
|
|
1535
1710
|
_url;
|
|
1711
|
+
_token;
|
|
1536
1712
|
_onMessage;
|
|
1537
1713
|
_ws = null;
|
|
1538
1714
|
_pending = [];
|
|
@@ -1541,9 +1717,16 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1541
1717
|
_connectReject = null;
|
|
1542
1718
|
_running = false;
|
|
1543
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;
|
|
1544
1726
|
constructor(wsUrl, token, onMessage) {
|
|
1545
1727
|
super();
|
|
1546
|
-
this._url =
|
|
1728
|
+
this._url = wsUrl;
|
|
1729
|
+
this._token = token;
|
|
1547
1730
|
this._onMessage = onMessage ?? null;
|
|
1548
1731
|
}
|
|
1549
1732
|
async connect() {
|
|
@@ -1564,7 +1747,14 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1564
1747
|
};
|
|
1565
1748
|
});
|
|
1566
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
|
+
*/
|
|
1567
1756
|
async disconnect() {
|
|
1757
|
+
await this._allAnswered();
|
|
1568
1758
|
this._running = false;
|
|
1569
1759
|
if (this._reconnectTimeout !== null) {
|
|
1570
1760
|
clearTimeout(this._reconnectTimeout);
|
|
@@ -1582,6 +1772,7 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1582
1772
|
throw new Error("WebSocket not connected");
|
|
1583
1773
|
}
|
|
1584
1774
|
this._ws.send(JSON.stringify(envelope));
|
|
1775
|
+
this._unanswered += 1;
|
|
1585
1776
|
}
|
|
1586
1777
|
async receive(limit = 50) {
|
|
1587
1778
|
const result = this._pending.slice(0, limit);
|
|
@@ -1591,11 +1782,32 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1591
1782
|
async listen(callback) {
|
|
1592
1783
|
this._onMessage = callback;
|
|
1593
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
|
+
}
|
|
1594
1806
|
_startConnectionLoop() {
|
|
1595
1807
|
let attempt = 0;
|
|
1596
1808
|
const tryConnect = () => {
|
|
1597
1809
|
if (!this._running) return;
|
|
1598
|
-
const ws = new WebSocket(this._url);
|
|
1810
|
+
const ws = new WebSocket(this._url, [`bearer.${this._token}`]);
|
|
1599
1811
|
this._ws = ws;
|
|
1600
1812
|
ws.on("open", () => {
|
|
1601
1813
|
this._connected = true;
|
|
@@ -1616,6 +1828,8 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1616
1828
|
ws.on("close", () => {
|
|
1617
1829
|
this._connected = false;
|
|
1618
1830
|
this._ws = null;
|
|
1831
|
+
this._unanswered = 0;
|
|
1832
|
+
this._settled?.();
|
|
1619
1833
|
if (!this._running) return;
|
|
1620
1834
|
attempt += 1;
|
|
1621
1835
|
const delay = Math.min(BASE_DELAY * Math.pow(2, attempt), MAX_DELAY);
|
|
@@ -1632,19 +1846,21 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1632
1846
|
}
|
|
1633
1847
|
_handleMessage(msg) {
|
|
1634
1848
|
const msgType = msg["type"];
|
|
1635
|
-
if (
|
|
1636
|
-
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
1637
|
-
this._ws.send(JSON.stringify({ type: "pong" }));
|
|
1638
|
-
}
|
|
1639
|
-
} else if (msgType === "ack") {
|
|
1640
|
-
} else if (msgType === "error" || "error" in msg) {
|
|
1641
|
-
} else if ("uam_version" in msg) {
|
|
1849
|
+
if ("uam_version" in msg) {
|
|
1642
1850
|
if (this._onMessage) {
|
|
1643
1851
|
this._onMessage(msg).catch(() => {
|
|
1644
1852
|
});
|
|
1645
1853
|
} else {
|
|
1646
1854
|
this._pending.push(msg);
|
|
1647
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();
|
|
1648
1864
|
}
|
|
1649
1865
|
}
|
|
1650
1866
|
};
|
|
@@ -1658,7 +1874,239 @@ function createTransport(config, token, address, onMessage) {
|
|
|
1658
1874
|
}
|
|
1659
1875
|
}
|
|
1660
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
|
+
|
|
1661
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
|
+
}
|
|
1662
2110
|
var Agent = class {
|
|
1663
2111
|
/** @internal */
|
|
1664
2112
|
_config;
|
|
@@ -1675,6 +2123,23 @@ var Agent = class {
|
|
|
1675
2123
|
_contactBook;
|
|
1676
2124
|
/** @internal */
|
|
1677
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();
|
|
1678
2143
|
/**
|
|
1679
2144
|
* Create an Agent. No I/O happens here -- call connect() to initialize.
|
|
1680
2145
|
*/
|
|
@@ -1696,6 +2161,13 @@ var Agent = class {
|
|
|
1696
2161
|
this._contactBook,
|
|
1697
2162
|
this._config.trustPolicy
|
|
1698
2163
|
);
|
|
2164
|
+
this._replayCache = new PersistentReplayCache(
|
|
2165
|
+
join4(this._config.dataDir, "replay", "replay.db"),
|
|
2166
|
+
{
|
|
2167
|
+
capacity: replayCacheCapacityFromEnv(),
|
|
2168
|
+
now: () => this._clock()
|
|
2169
|
+
}
|
|
2170
|
+
);
|
|
1699
2171
|
}
|
|
1700
2172
|
// -- Properties ----------------------------------------------------------
|
|
1701
2173
|
/** The agent's full UAM address (e.g., 'myagent::youam.network'). */
|
|
@@ -1709,7 +2181,7 @@ var Agent = class {
|
|
|
1709
2181
|
}
|
|
1710
2182
|
/** UAM SDK version. */
|
|
1711
2183
|
get version() {
|
|
1712
|
-
return
|
|
2184
|
+
return SDK_VERSION;
|
|
1713
2185
|
}
|
|
1714
2186
|
/** The agent's public key (base64-encoded Ed25519 verify key). */
|
|
1715
2187
|
get publicKey() {
|
|
@@ -1746,10 +2218,19 @@ var Agent = class {
|
|
|
1746
2218
|
}
|
|
1747
2219
|
await sodiumReady;
|
|
1748
2220
|
this._keyManager.loadOrGenerate(this._config.name);
|
|
1749
|
-
|
|
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
|
+
);
|
|
1750
2231
|
if (storedToken) {
|
|
1751
2232
|
this._token = storedToken;
|
|
1752
|
-
this.
|
|
2233
|
+
this._useAddress(await this._storedAddress());
|
|
1753
2234
|
} else if (this._autoRegister) {
|
|
1754
2235
|
await this._registerWithRelay();
|
|
1755
2236
|
} else {
|
|
@@ -1772,6 +2253,7 @@ var Agent = class {
|
|
|
1772
2253
|
*/
|
|
1773
2254
|
async close() {
|
|
1774
2255
|
this._contactBook.close();
|
|
2256
|
+
await this._replayCache.close();
|
|
1775
2257
|
if (this._transport) {
|
|
1776
2258
|
await this._transport.disconnect();
|
|
1777
2259
|
}
|
|
@@ -1792,7 +2274,8 @@ var Agent = class {
|
|
|
1792
2274
|
}
|
|
1793
2275
|
}
|
|
1794
2276
|
const recipientVk = await this._resolvePublicKey(toAddress);
|
|
1795
|
-
|
|
2277
|
+
const trustState = this._contactBook.getTrustState(toAddress);
|
|
2278
|
+
if (trustState !== "pinned" && trustState !== "verified" && trustState !== "trusted" && trustState !== "handshake-sent") {
|
|
1796
2279
|
await this._initiateHandshake(toAddress, recipientVk);
|
|
1797
2280
|
}
|
|
1798
2281
|
const envelope = await createEnvelope(
|
|
@@ -1823,7 +2306,10 @@ var Agent = class {
|
|
|
1823
2306
|
* Retrieve, decrypt, and verify pending messages.
|
|
1824
2307
|
*
|
|
1825
2308
|
* Returns a list of ReceivedMessage data objects.
|
|
1826
|
-
*
|
|
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.
|
|
1827
2313
|
*/
|
|
1828
2314
|
async inbox(limit = 50) {
|
|
1829
2315
|
await this._ensureConnected();
|
|
@@ -1831,7 +2317,15 @@ var Agent = class {
|
|
|
1831
2317
|
const rawMessages = await this._transport.receive(limit);
|
|
1832
2318
|
const result = [];
|
|
1833
2319
|
for (const raw of rawMessages) {
|
|
1834
|
-
|
|
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
|
+
}
|
|
1835
2329
|
if (msg !== null) {
|
|
1836
2330
|
result.push(msg);
|
|
1837
2331
|
await this._sendReadReceipt(msg);
|
|
@@ -1861,13 +2355,14 @@ var Agent = class {
|
|
|
1861
2355
|
const cardDict = JSON.parse(entry.contactCard);
|
|
1862
2356
|
const card = contactCardFromDict(cardDict);
|
|
1863
2357
|
verifyContactCard(card);
|
|
2358
|
+
const senderVk = deserializeVerifyKey(card.publicKey);
|
|
2359
|
+
bindCardToEnvelope(card, { fromAddress: address }, senderVk);
|
|
1864
2360
|
this._contactBook.addContact(card.address, card.publicKey, {
|
|
1865
2361
|
displayName: card.displayName,
|
|
1866
2362
|
trustState: "trusted",
|
|
1867
2363
|
trustSource: "explicit-approval"
|
|
1868
2364
|
});
|
|
1869
2365
|
this._contactBook.removePending(address);
|
|
1870
|
-
const senderVk = deserializeVerifyKey(card.publicKey);
|
|
1871
2366
|
await this._handshake._sendAccept(this, address, senderVk);
|
|
1872
2367
|
}
|
|
1873
2368
|
/**
|
|
@@ -2004,6 +2499,10 @@ var Agent = class {
|
|
|
2004
2499
|
/**
|
|
2005
2500
|
* Resolve a recipient's public key, checking contact book first.
|
|
2006
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
|
+
*
|
|
2007
2506
|
* TOFU-01: Known contacts always use stored key (zero network I/O).
|
|
2008
2507
|
* TOFU-03: Race-condition double-check after network resolve.
|
|
2009
2508
|
* Raises KeyPinningError if a pinned contact's key doesn't match.
|
|
@@ -2027,9 +2526,6 @@ var Agent = class {
|
|
|
2027
2526
|
);
|
|
2028
2527
|
}
|
|
2029
2528
|
}
|
|
2030
|
-
this._contactBook.addContact(toAddress, resolvedPkStr, {
|
|
2031
|
-
trustState: "provisional"
|
|
2032
|
-
});
|
|
2033
2529
|
return deserializeVerifyKey(resolvedPkStr);
|
|
2034
2530
|
}
|
|
2035
2531
|
async _initiateHandshake(toAddress, recipientVk) {
|
|
@@ -2076,6 +2572,9 @@ var Agent = class {
|
|
|
2076
2572
|
}
|
|
2077
2573
|
throw err;
|
|
2078
2574
|
}
|
|
2575
|
+
if (!await this._passesInboundChecks(envelope, raw)) {
|
|
2576
|
+
return null;
|
|
2577
|
+
}
|
|
2079
2578
|
if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */ || envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */ || envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
|
|
2080
2579
|
return this._handshake.handleInbound(this, envelope, senderVk);
|
|
2081
2580
|
}
|
|
@@ -2110,6 +2609,102 @@ var Agent = class {
|
|
|
2110
2609
|
verified: true
|
|
2111
2610
|
});
|
|
2112
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
|
+
}
|
|
2113
2708
|
/**
|
|
2114
2709
|
* Sweep expired pending handshakes and send receipt.failed (HAND-03).
|
|
2115
2710
|
*/
|
|
@@ -2162,7 +2757,8 @@ var Agent = class {
|
|
|
2162
2757
|
"receipt.read" /* RECEIPT_READ */,
|
|
2163
2758
|
new TextEncoder().encode(receiptPayload),
|
|
2164
2759
|
this._keyManager.signingKey,
|
|
2165
|
-
senderVk
|
|
2760
|
+
senderVk,
|
|
2761
|
+
{ replyTo: msg.messageId }
|
|
2166
2762
|
);
|
|
2167
2763
|
const wire = toWireDict(envelope);
|
|
2168
2764
|
await this._transport.send(wire);
|
|
@@ -2172,26 +2768,25 @@ var Agent = class {
|
|
|
2172
2768
|
/**
|
|
2173
2769
|
* Register with the relay server.
|
|
2174
2770
|
*
|
|
2175
|
-
* Calls POST /api/v1/register with agent name and public key.
|
|
2176
|
-
*
|
|
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.
|
|
2177
2774
|
*/
|
|
2178
2775
|
async _registerWithRelay() {
|
|
2179
|
-
const
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
headers: { "Content-Type": "application/json" },
|
|
2185
|
-
body: JSON.stringify({
|
|
2186
|
-
agent_name: this._config.name,
|
|
2187
|
-
public_key: publicKeyStr
|
|
2188
|
-
})
|
|
2189
|
-
}
|
|
2190
|
-
);
|
|
2776
|
+
const body = {
|
|
2777
|
+
agent_name: this._config.name,
|
|
2778
|
+
public_key: serializeVerifyKey(this._keyManager.verifyKey)
|
|
2779
|
+
};
|
|
2780
|
+
let resp = await this._postRegistration(body);
|
|
2191
2781
|
if (resp.status === 409) {
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
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
|
+
}
|
|
2195
2790
|
}
|
|
2196
2791
|
if (!resp.ok) {
|
|
2197
2792
|
const text = await resp.text();
|
|
@@ -2200,19 +2795,152 @@ var Agent = class {
|
|
|
2200
2795
|
);
|
|
2201
2796
|
}
|
|
2202
2797
|
const data = await resp.json();
|
|
2203
|
-
|
|
2798
|
+
const address = data["address"];
|
|
2799
|
+
this._useAddress(address);
|
|
2204
2800
|
this._token = data["token"];
|
|
2205
|
-
|
|
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
|
+
}
|
|
2206
2919
|
}
|
|
2207
2920
|
};
|
|
2208
2921
|
|
|
2209
2922
|
// src/sdk/webhook-verify.ts
|
|
2210
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
|
+
}
|
|
2211
2937
|
|
|
2212
2938
|
// src/sdk/index.ts
|
|
2213
2939
|
init_dns_verifier();
|
|
2214
2940
|
export {
|
|
2215
2941
|
Agent,
|
|
2216
|
-
|
|
2942
|
+
ContactBook,
|
|
2943
|
+
protocol_exports as protocol,
|
|
2944
|
+
verifyWebhookSignature
|
|
2217
2945
|
};
|
|
2218
2946
|
//# sourceMappingURL=index.js.map
|