@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.cjs
CHANGED
|
@@ -115,10 +115,15 @@ var init_dns_verifier = __esm({
|
|
|
115
115
|
var index_exports = {};
|
|
116
116
|
__export(index_exports, {
|
|
117
117
|
Agent: () => Agent,
|
|
118
|
-
|
|
118
|
+
ContactBook: () => ContactBook,
|
|
119
|
+
protocol: () => protocol_exports,
|
|
120
|
+
verifyWebhookSignature: () => verifyWebhookSignature
|
|
119
121
|
});
|
|
120
122
|
module.exports = __toCommonJS(index_exports);
|
|
121
123
|
|
|
124
|
+
// src/sdk/agent.ts
|
|
125
|
+
var import_node_path5 = require("path");
|
|
126
|
+
|
|
122
127
|
// src/protocol/index.ts
|
|
123
128
|
var protocol_exports = {};
|
|
124
129
|
__export(protocol_exports, {
|
|
@@ -129,6 +134,7 @@ __export(protocol_exports, {
|
|
|
129
134
|
InvalidContactCardError: () => InvalidContactCardError,
|
|
130
135
|
InvalidEnvelopeError: () => InvalidEnvelopeError,
|
|
131
136
|
KeyPinningError: () => KeyPinningError,
|
|
137
|
+
MAX_ENVELOPE_AGE: () => MAX_ENVELOPE_AGE,
|
|
132
138
|
MAX_ENVELOPE_SIZE: () => MAX_ENVELOPE_SIZE,
|
|
133
139
|
MessageType: () => MessageType,
|
|
134
140
|
SignatureError: () => SignatureError,
|
|
@@ -168,6 +174,7 @@ __export(protocol_exports, {
|
|
|
168
174
|
// src/protocol/types.ts
|
|
169
175
|
var UAM_VERSION = "0.1";
|
|
170
176
|
var MAX_ENVELOPE_SIZE = 65536;
|
|
177
|
+
var MAX_ENVELOPE_AGE = 300;
|
|
171
178
|
var MessageType = /* @__PURE__ */ ((MessageType2) => {
|
|
172
179
|
MessageType2["MESSAGE"] = "message";
|
|
173
180
|
MessageType2["HANDSHAKE_REQUEST"] = "handshake.request";
|
|
@@ -692,10 +699,16 @@ function verifyContactCard(card) {
|
|
|
692
699
|
verifySignature(canonicalize(signable), card.signature, vk);
|
|
693
700
|
}
|
|
694
701
|
|
|
702
|
+
// src/version.ts
|
|
703
|
+
var SDK_VERSION = "0.2.1";
|
|
704
|
+
|
|
695
705
|
// src/sdk/config.ts
|
|
696
706
|
var import_node_os = require("os");
|
|
697
707
|
var import_node_path = require("path");
|
|
698
708
|
var DEFAULT_RELAY_URL = "https://relay.youam.network";
|
|
709
|
+
function trimRelayUrl(url) {
|
|
710
|
+
return url.replace(/\/+$/, "");
|
|
711
|
+
}
|
|
699
712
|
var VALID_POLICIES = /* @__PURE__ */ new Set([
|
|
700
713
|
"auto-accept",
|
|
701
714
|
"approval-required",
|
|
@@ -712,9 +725,13 @@ var SDKConfig = class {
|
|
|
712
725
|
transportType;
|
|
713
726
|
trustPolicy;
|
|
714
727
|
relayDomain;
|
|
728
|
+
/** The domain the caller chose, if any; null when only derived. */
|
|
729
|
+
explicitRelayDomain;
|
|
715
730
|
constructor(options) {
|
|
716
731
|
this.name = options.name;
|
|
717
|
-
this.relayUrl =
|
|
732
|
+
this.relayUrl = trimRelayUrl(
|
|
733
|
+
options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL
|
|
734
|
+
);
|
|
718
735
|
if (options.relayWsUrl) {
|
|
719
736
|
this.relayWsUrl = options.relayWsUrl;
|
|
720
737
|
} else {
|
|
@@ -725,6 +742,7 @@ var SDKConfig = class {
|
|
|
725
742
|
this.relayWsUrl = wsUrl;
|
|
726
743
|
}
|
|
727
744
|
const envDomain = process.env["UAM_RELAY_DOMAIN"];
|
|
745
|
+
this.explicitRelayDomain = envDomain || options.relayDomain || null;
|
|
728
746
|
if (envDomain) {
|
|
729
747
|
this.relayDomain = envDomain;
|
|
730
748
|
} else if (options.relayDomain) {
|
|
@@ -758,6 +776,27 @@ var SDKConfig = class {
|
|
|
758
776
|
}
|
|
759
777
|
};
|
|
760
778
|
|
|
779
|
+
// src/sdk/freshness.ts
|
|
780
|
+
var RELAY_META_FEATURE = "relay_meta";
|
|
781
|
+
var STORED_MAIL_MAX_AGE = 7 * 86400;
|
|
782
|
+
var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
783
|
+
function relayVouches(raw, signedAt, now, relayDomain) {
|
|
784
|
+
const meta = raw["relay_meta"];
|
|
785
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
const stamp = meta;
|
|
789
|
+
if (stamp["v"] !== 1 || stamp["relay"] !== relayDomain) return false;
|
|
790
|
+
if (stamp["stored"] !== true) return false;
|
|
791
|
+
const received = stamp["received_at"];
|
|
792
|
+
if (typeof received !== "string" || !ISO_TIME.test(received)) return false;
|
|
793
|
+
const receivedAt = Date.parse(received);
|
|
794
|
+
if (Number.isNaN(receivedAt)) return false;
|
|
795
|
+
const window = MAX_ENVELOPE_AGE * 1e3;
|
|
796
|
+
const age = now - signedAt;
|
|
797
|
+
return Math.abs(receivedAt - signedAt) <= window && age >= 0 && age <= STORED_MAIL_MAX_AGE * 1e3 + window;
|
|
798
|
+
}
|
|
799
|
+
|
|
761
800
|
// src/sdk/key-manager.ts
|
|
762
801
|
var import_node_fs = require("fs");
|
|
763
802
|
var import_node_fs2 = require("fs");
|
|
@@ -828,17 +867,54 @@ var KeyManager = class {
|
|
|
828
867
|
(0, import_node_fs.writeFileSync)(tokenPath, token);
|
|
829
868
|
this._setPermissions(tokenPath);
|
|
830
869
|
}
|
|
870
|
+
/**
|
|
871
|
+
* Store the address `relayUrl` gave this agent.
|
|
872
|
+
*/
|
|
873
|
+
saveAddress(name, address, relayUrl) {
|
|
874
|
+
(0, import_node_fs.mkdirSync)(this._keyDir, { recursive: true });
|
|
875
|
+
(0, import_node_fs.writeFileSync)(
|
|
876
|
+
(0, import_node_path2.join)(this._keyDir, `${name}.address`),
|
|
877
|
+
JSON.stringify({ address, relay_url: relayUrl })
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Load the address saved for `relayUrl`.
|
|
882
|
+
*
|
|
883
|
+
* Returns null if there is none: the file is missing, unreadable or
|
|
884
|
+
* malformed, or it belongs to another relay.
|
|
885
|
+
*/
|
|
886
|
+
loadAddress(name, relayUrl) {
|
|
887
|
+
try {
|
|
888
|
+
const path = (0, import_node_path2.join)(this._keyDir, `${name}.address`);
|
|
889
|
+
const saved = JSON.parse((0, import_node_fs.readFileSync)(path, "utf-8"));
|
|
890
|
+
const address = saved?.address;
|
|
891
|
+
const savedRelay = saved?.relay_url;
|
|
892
|
+
if (typeof address !== "string" || typeof savedRelay !== "string") {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
if (trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl)) return null;
|
|
896
|
+
parseAddress(address);
|
|
897
|
+
return address;
|
|
898
|
+
} catch {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
831
902
|
/**
|
|
832
903
|
* Load a previously saved token, or return null.
|
|
833
904
|
*
|
|
834
905
|
* Checks UAM_TOKEN env var first, then file-based storage,
|
|
835
|
-
* then legacy .api_key files for backward compatibility.
|
|
906
|
+
* then legacy .api_key files for backward compatibility. With `relayUrl`,
|
|
907
|
+
* a saved token that belongs to another relay counts as none, so it is
|
|
908
|
+
* only offered to the relay that issued it.
|
|
836
909
|
*/
|
|
837
|
-
loadToken(name) {
|
|
910
|
+
loadToken(name, relayUrl) {
|
|
838
911
|
const envToken = process.env["UAM_TOKEN"];
|
|
839
912
|
if (envToken) {
|
|
840
913
|
return envToken.trim();
|
|
841
914
|
}
|
|
915
|
+
if (relayUrl !== void 0 && this._savedForAnotherRelay(name, relayUrl)) {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
842
918
|
const tokenPath = (0, import_node_path2.join)(this._keyDir, `${name}.token`);
|
|
843
919
|
if ((0, import_node_fs2.existsSync)(tokenPath)) {
|
|
844
920
|
return (0, import_node_fs.readFileSync)(tokenPath, "utf-8").trim();
|
|
@@ -849,6 +925,20 @@ var KeyManager = class {
|
|
|
849
925
|
}
|
|
850
926
|
return null;
|
|
851
927
|
}
|
|
928
|
+
/**
|
|
929
|
+
* True if the saved address says this agent's files are another relay's.
|
|
930
|
+
* A missing or damaged file says nothing.
|
|
931
|
+
*/
|
|
932
|
+
_savedForAnotherRelay(name, relayUrl) {
|
|
933
|
+
try {
|
|
934
|
+
const path = (0, import_node_path2.join)(this._keyDir, `${name}.address`);
|
|
935
|
+
const saved = JSON.parse((0, import_node_fs.readFileSync)(path, "utf-8"));
|
|
936
|
+
const savedRelay = saved?.relay_url;
|
|
937
|
+
return typeof savedRelay === "string" && trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl);
|
|
938
|
+
} catch {
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
852
942
|
/** Set file permissions to 0o600 (owner read/write only). */
|
|
853
943
|
_setPermissions(path) {
|
|
854
944
|
if ((0, import_node_os3.platform)() !== "win32") {
|
|
@@ -876,6 +966,20 @@ var KeyManager = class {
|
|
|
876
966
|
var import_node_fs3 = require("fs");
|
|
877
967
|
var import_node_path3 = require("path");
|
|
878
968
|
var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
|
|
969
|
+
|
|
970
|
+
// src/sdk/log.ts
|
|
971
|
+
var log = {
|
|
972
|
+
warn(message) {
|
|
973
|
+
console.warn(`[uam] ${message}`);
|
|
974
|
+
},
|
|
975
|
+
info(message) {
|
|
976
|
+
if (process.env["UAM_DEBUG"]) {
|
|
977
|
+
console.info(`[uam] ${message}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
// src/sdk/contact-book.ts
|
|
879
983
|
var ContactBook = class {
|
|
880
984
|
_dbPath;
|
|
881
985
|
_db = null;
|
|
@@ -996,11 +1100,30 @@ var ContactBook = class {
|
|
|
996
1100
|
}
|
|
997
1101
|
/**
|
|
998
1102
|
* Add or update a contact (upsert).
|
|
1103
|
+
*
|
|
1104
|
+
* Refuses to replace the key of a contact whose trust_state is "pinned"
|
|
1105
|
+
* or "verified" with a different key, unless `force: true` is passed (for
|
|
1106
|
+
* a deliberate key rotation, which is logged). Matches the Python SDK's
|
|
1107
|
+
* ContactBook.add_contact.
|
|
1108
|
+
*
|
|
1109
|
+
* @throws {KeyPinningError} If a pinned/verified contact's key would be
|
|
1110
|
+
* replaced by a different key without `force`.
|
|
999
1111
|
*/
|
|
1000
1112
|
addContact(address, publicKey, options) {
|
|
1001
1113
|
if (this._db === null) {
|
|
1002
1114
|
throw new Error("ContactBook not open. Call open() first.");
|
|
1003
1115
|
}
|
|
1116
|
+
const existing = this._db.prepare("SELECT public_key, trust_state FROM contacts WHERE address = ?").get(address);
|
|
1117
|
+
if (existing !== void 0 && (existing.trust_state === "pinned" || existing.trust_state === "verified") && existing.public_key !== publicKey) {
|
|
1118
|
+
if (!options?.force) {
|
|
1119
|
+
throw new KeyPinningError(
|
|
1120
|
+
`Refusing to overwrite ${existing.trust_state} contact ${JSON.stringify(address)}: stored public key does not match the new key. Pass force: true to rotate.`
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
log.warn(
|
|
1124
|
+
`Force-replacing the key of ${existing.trust_state} contact ${address} (key rotation)`
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1004
1127
|
const displayName = options?.displayName ?? null;
|
|
1005
1128
|
const trustState = options?.trustState ?? "trusted";
|
|
1006
1129
|
const trustSource = options?.trustSource ?? null;
|
|
@@ -1173,6 +1296,18 @@ var ContactBook = class {
|
|
|
1173
1296
|
};
|
|
1174
1297
|
|
|
1175
1298
|
// src/sdk/handshake.ts
|
|
1299
|
+
function bindCardToEnvelope(card, envelope, senderVk) {
|
|
1300
|
+
if (card.address !== envelope.fromAddress) {
|
|
1301
|
+
throw new InvalidEnvelopeError(
|
|
1302
|
+
`Contact card address ${JSON.stringify(card.address)} does not match envelope sender ${JSON.stringify(envelope.fromAddress)}`
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
if (card.publicKey !== serializeVerifyKey(senderVk)) {
|
|
1306
|
+
throw new InvalidEnvelopeError(
|
|
1307
|
+
"Contact card public_key does not match envelope sender's verify key"
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1176
1311
|
var HandshakeManager = class {
|
|
1177
1312
|
_contactBook;
|
|
1178
1313
|
_trustPolicy;
|
|
@@ -1215,7 +1350,7 @@ var HandshakeManager = class {
|
|
|
1215
1350
|
if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */) {
|
|
1216
1351
|
await this._handleRequest(agent, envelope, senderVk);
|
|
1217
1352
|
} else if (envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */) {
|
|
1218
|
-
await this._handleAccept(envelope, senderVk);
|
|
1353
|
+
await this._handleAccept(agent, envelope, senderVk);
|
|
1219
1354
|
} else if (envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
|
|
1220
1355
|
}
|
|
1221
1356
|
return null;
|
|
@@ -1233,6 +1368,7 @@ var HandshakeManager = class {
|
|
|
1233
1368
|
);
|
|
1234
1369
|
const card = contactCardFromDict(cardDict);
|
|
1235
1370
|
verifyContactCard(card);
|
|
1371
|
+
bindCardToEnvelope(card, envelope, senderVk);
|
|
1236
1372
|
if (this._trustPolicy === "auto-accept") {
|
|
1237
1373
|
this._contactBook.addContact(card.address, card.publicKey, {
|
|
1238
1374
|
displayName: card.displayName,
|
|
@@ -1251,8 +1387,46 @@ var HandshakeManager = class {
|
|
|
1251
1387
|
}
|
|
1252
1388
|
/**
|
|
1253
1389
|
* Process a handshake.accept: store the sender as pinned (TOFU).
|
|
1390
|
+
*
|
|
1391
|
+
* Only honoured when:
|
|
1392
|
+
* 1. we previously sent this address a handshake.request
|
|
1393
|
+
* (trust_state === "handshake-sent"); anything else is an unsolicited
|
|
1394
|
+
* accept and is dropped. This also means an accept can never
|
|
1395
|
+
* downgrade a trusted/verified contact to pinned;
|
|
1396
|
+
* 2. the payload decrypts with our key and the sender's key, and parses
|
|
1397
|
+
* as JSON. Otherwise it is dropped without pinning;
|
|
1398
|
+
* 3. an embedded contact_card, if present, is well-formed, correctly
|
|
1399
|
+
* self-signed, and bound to the envelope sender (throws otherwise).
|
|
1254
1400
|
*/
|
|
1255
|
-
async _handleAccept(envelope, senderVk) {
|
|
1401
|
+
async _handleAccept(agent, envelope, senderVk) {
|
|
1402
|
+
const prior = this._contactBook.getTrustState(envelope.fromAddress);
|
|
1403
|
+
if (prior !== "handshake-sent") {
|
|
1404
|
+
log.warn(
|
|
1405
|
+
`Ignoring unsolicited handshake.accept from ${envelope.fromAddress} (trust_state=${JSON.stringify(prior)})`
|
|
1406
|
+
);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
let payload;
|
|
1410
|
+
try {
|
|
1411
|
+
const plaintext = decryptPayload(
|
|
1412
|
+
envelope.payload,
|
|
1413
|
+
agent._keyManager.signingKey,
|
|
1414
|
+
senderVk
|
|
1415
|
+
);
|
|
1416
|
+
payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
1417
|
+
} catch {
|
|
1418
|
+
log.warn(
|
|
1419
|
+
`Dropping handshake.accept ${envelope.messageId} from ${envelope.fromAddress}: payload could not be decrypted or parsed`
|
|
1420
|
+
);
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
if (payload !== null && typeof payload === "object" && "contact_card" in payload) {
|
|
1424
|
+
const card = contactCardFromDict(
|
|
1425
|
+
payload["contact_card"]
|
|
1426
|
+
);
|
|
1427
|
+
verifyContactCard(card);
|
|
1428
|
+
bindCardToEnvelope(card, envelope, senderVk);
|
|
1429
|
+
}
|
|
1256
1430
|
const senderPkStr = serializeVerifyKey(senderVk);
|
|
1257
1431
|
this._contactBook.addContact(envelope.fromAddress, senderPkStr, {
|
|
1258
1432
|
trustState: "pinned"
|
|
@@ -1309,6 +1483,10 @@ var HandshakeManager = class {
|
|
|
1309
1483
|
}
|
|
1310
1484
|
};
|
|
1311
1485
|
|
|
1486
|
+
// src/sdk/resolver-base.ts
|
|
1487
|
+
var AddressResolver = class {
|
|
1488
|
+
};
|
|
1489
|
+
|
|
1312
1490
|
// src/sdk/tier3.ts
|
|
1313
1491
|
var import_viem = require("viem");
|
|
1314
1492
|
var DEFAULT_RPC_URL = "https://sepolia.base.org";
|
|
@@ -1425,8 +1603,6 @@ var Tier3Resolver = class extends AddressResolver {
|
|
|
1425
1603
|
|
|
1426
1604
|
// src/sdk/resolver.ts
|
|
1427
1605
|
init_dns_verifier();
|
|
1428
|
-
var AddressResolver = class {
|
|
1429
|
-
};
|
|
1430
1606
|
var Tier1Resolver = class extends AddressResolver {
|
|
1431
1607
|
async resolvePublicKey(address, _token, relayUrl) {
|
|
1432
1608
|
const resp = await fetch(
|
|
@@ -1463,13 +1639,13 @@ var Tier2Resolver = class extends AddressResolver {
|
|
|
1463
1639
|
}
|
|
1464
1640
|
};
|
|
1465
1641
|
var SmartResolver = class extends AddressResolver {
|
|
1466
|
-
|
|
1642
|
+
relayDomain;
|
|
1467
1643
|
_tier1;
|
|
1468
1644
|
_tier2;
|
|
1469
1645
|
_tier3;
|
|
1470
1646
|
constructor(relayDomain, tier3Config) {
|
|
1471
1647
|
super();
|
|
1472
|
-
this.
|
|
1648
|
+
this.relayDomain = relayDomain;
|
|
1473
1649
|
this._tier1 = new Tier1Resolver();
|
|
1474
1650
|
this._tier2 = new Tier2Resolver();
|
|
1475
1651
|
this._tier3 = tier3Config ? new Tier3Resolver(tier3Config) : null;
|
|
@@ -1477,7 +1653,7 @@ var SmartResolver = class extends AddressResolver {
|
|
|
1477
1653
|
async resolvePublicKey(address, token, relayUrl) {
|
|
1478
1654
|
const parsed = parseAddress(address);
|
|
1479
1655
|
const domain = parsed.domain;
|
|
1480
|
-
if (domain === this.
|
|
1656
|
+
if (domain === this.relayDomain) {
|
|
1481
1657
|
return this._tier1.resolvePublicKey(address, token, relayUrl);
|
|
1482
1658
|
}
|
|
1483
1659
|
if (domain.includes(".")) {
|
|
@@ -1556,8 +1732,10 @@ var import_ws = __toESM(require("ws"), 1);
|
|
|
1556
1732
|
var BASE_DELAY = 1;
|
|
1557
1733
|
var MAX_DELAY = 60;
|
|
1558
1734
|
var JITTER_RANGE = 1;
|
|
1735
|
+
var SEND_SETTLE_SECONDS = 5;
|
|
1559
1736
|
var WebSocketTransport = class extends TransportBase {
|
|
1560
1737
|
_url;
|
|
1738
|
+
_token;
|
|
1561
1739
|
_onMessage;
|
|
1562
1740
|
_ws = null;
|
|
1563
1741
|
_pending = [];
|
|
@@ -1566,9 +1744,16 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1566
1744
|
_connectReject = null;
|
|
1567
1745
|
_running = false;
|
|
1568
1746
|
_reconnectTimeout = null;
|
|
1747
|
+
/** Envelopes sent that the relay hasn't answered yet. */
|
|
1748
|
+
_unanswered = 0;
|
|
1749
|
+
_settling = null;
|
|
1750
|
+
_settled = null;
|
|
1751
|
+
/** @internal How long a close waits, in ms. Replaceable in tests. */
|
|
1752
|
+
_settleMs = SEND_SETTLE_SECONDS * 1e3;
|
|
1569
1753
|
constructor(wsUrl, token, onMessage) {
|
|
1570
1754
|
super();
|
|
1571
|
-
this._url =
|
|
1755
|
+
this._url = wsUrl;
|
|
1756
|
+
this._token = token;
|
|
1572
1757
|
this._onMessage = onMessage ?? null;
|
|
1573
1758
|
}
|
|
1574
1759
|
async connect() {
|
|
@@ -1589,7 +1774,14 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1589
1774
|
};
|
|
1590
1775
|
});
|
|
1591
1776
|
}
|
|
1777
|
+
/**
|
|
1778
|
+
* Close the connection, once the relay has answered what was sent.
|
|
1779
|
+
*
|
|
1780
|
+
* The relay stops reading a socket when it can't write an ack to it, so
|
|
1781
|
+
* closing straight after send() loses every frame but the first.
|
|
1782
|
+
*/
|
|
1592
1783
|
async disconnect() {
|
|
1784
|
+
await this._allAnswered();
|
|
1593
1785
|
this._running = false;
|
|
1594
1786
|
if (this._reconnectTimeout !== null) {
|
|
1595
1787
|
clearTimeout(this._reconnectTimeout);
|
|
@@ -1607,6 +1799,7 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1607
1799
|
throw new Error("WebSocket not connected");
|
|
1608
1800
|
}
|
|
1609
1801
|
this._ws.send(JSON.stringify(envelope));
|
|
1802
|
+
this._unanswered += 1;
|
|
1610
1803
|
}
|
|
1611
1804
|
async receive(limit = 50) {
|
|
1612
1805
|
const result = this._pending.slice(0, limit);
|
|
@@ -1616,11 +1809,32 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1616
1809
|
async listen(callback) {
|
|
1617
1810
|
this._onMessage = callback;
|
|
1618
1811
|
}
|
|
1812
|
+
/** Resolves when nothing is unanswered, or after _settleMs regardless. */
|
|
1813
|
+
_allAnswered() {
|
|
1814
|
+
if (this._unanswered === 0) return Promise.resolve();
|
|
1815
|
+
this._settling ??= new Promise((resolve) => {
|
|
1816
|
+
const timeout = setTimeout(() => {
|
|
1817
|
+
log.warn(`Closing with ${this._unanswered} sends unanswered`);
|
|
1818
|
+
this._settled?.();
|
|
1819
|
+
}, this._settleMs);
|
|
1820
|
+
this._settled = () => {
|
|
1821
|
+
clearTimeout(timeout);
|
|
1822
|
+
this._settling = null;
|
|
1823
|
+
this._settled = null;
|
|
1824
|
+
resolve();
|
|
1825
|
+
};
|
|
1826
|
+
});
|
|
1827
|
+
return this._settling;
|
|
1828
|
+
}
|
|
1829
|
+
_answered() {
|
|
1830
|
+
this._unanswered = Math.max(0, this._unanswered - 1);
|
|
1831
|
+
if (this._unanswered === 0) this._settled?.();
|
|
1832
|
+
}
|
|
1619
1833
|
_startConnectionLoop() {
|
|
1620
1834
|
let attempt = 0;
|
|
1621
1835
|
const tryConnect = () => {
|
|
1622
1836
|
if (!this._running) return;
|
|
1623
|
-
const ws = new import_ws.default(this._url);
|
|
1837
|
+
const ws = new import_ws.default(this._url, [`bearer.${this._token}`]);
|
|
1624
1838
|
this._ws = ws;
|
|
1625
1839
|
ws.on("open", () => {
|
|
1626
1840
|
this._connected = true;
|
|
@@ -1641,6 +1855,8 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1641
1855
|
ws.on("close", () => {
|
|
1642
1856
|
this._connected = false;
|
|
1643
1857
|
this._ws = null;
|
|
1858
|
+
this._unanswered = 0;
|
|
1859
|
+
this._settled?.();
|
|
1644
1860
|
if (!this._running) return;
|
|
1645
1861
|
attempt += 1;
|
|
1646
1862
|
const delay = Math.min(BASE_DELAY * Math.pow(2, attempt), MAX_DELAY);
|
|
@@ -1657,19 +1873,21 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1657
1873
|
}
|
|
1658
1874
|
_handleMessage(msg) {
|
|
1659
1875
|
const msgType = msg["type"];
|
|
1660
|
-
if (
|
|
1661
|
-
if (this._ws && this._ws.readyState === import_ws.default.OPEN) {
|
|
1662
|
-
this._ws.send(JSON.stringify({ type: "pong" }));
|
|
1663
|
-
}
|
|
1664
|
-
} else if (msgType === "ack") {
|
|
1665
|
-
} else if (msgType === "error" || "error" in msg) {
|
|
1666
|
-
} else if ("uam_version" in msg) {
|
|
1876
|
+
if ("uam_version" in msg) {
|
|
1667
1877
|
if (this._onMessage) {
|
|
1668
1878
|
this._onMessage(msg).catch(() => {
|
|
1669
1879
|
});
|
|
1670
1880
|
} else {
|
|
1671
1881
|
this._pending.push(msg);
|
|
1672
1882
|
}
|
|
1883
|
+
} else if (msgType === "ping") {
|
|
1884
|
+
if (this._ws && this._ws.readyState === import_ws.default.OPEN) {
|
|
1885
|
+
this._ws.send(JSON.stringify({ type: "pong" }));
|
|
1886
|
+
}
|
|
1887
|
+
} else if (msgType === "ack") {
|
|
1888
|
+
this._answered();
|
|
1889
|
+
} else if (msgType === "error" || "error" in msg) {
|
|
1890
|
+
this._answered();
|
|
1673
1891
|
}
|
|
1674
1892
|
}
|
|
1675
1893
|
};
|
|
@@ -1683,7 +1901,239 @@ function createTransport(config, token, address, onMessage) {
|
|
|
1683
1901
|
}
|
|
1684
1902
|
}
|
|
1685
1903
|
|
|
1904
|
+
// src/sdk/replay-cache.ts
|
|
1905
|
+
var import_node_fs4 = require("fs");
|
|
1906
|
+
var import_node_path4 = require("path");
|
|
1907
|
+
var import_promises2 = require("timers/promises");
|
|
1908
|
+
var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
|
|
1909
|
+
var RETENTION_SECONDS = STORED_MAIL_MAX_AGE + 2 * MAX_ENVELOPE_AGE;
|
|
1910
|
+
var PURGE_INTERVAL_MS = 6e4;
|
|
1911
|
+
var WAL_ATTEMPTS = 100;
|
|
1912
|
+
var WAL_RETRY_MS = 100;
|
|
1913
|
+
var SCHEMA = `
|
|
1914
|
+
CREATE TABLE IF NOT EXISTS seen_envelopes (
|
|
1915
|
+
from_address TEXT NOT NULL,
|
|
1916
|
+
message_id TEXT NOT NULL,
|
|
1917
|
+
seen_at REAL NOT NULL,
|
|
1918
|
+
PRIMARY KEY (from_address, message_id)
|
|
1919
|
+
) WITHOUT ROWID;
|
|
1920
|
+
CREATE INDEX IF NOT EXISTS seen_envelopes_seen_at ON seen_envelopes (seen_at);
|
|
1921
|
+
CREATE TABLE IF NOT EXISTS replay_meta (
|
|
1922
|
+
key TEXT PRIMARY KEY,
|
|
1923
|
+
value TEXT NOT NULL
|
|
1924
|
+
);
|
|
1925
|
+
`;
|
|
1926
|
+
function isLocked(err) {
|
|
1927
|
+
const code = err.code;
|
|
1928
|
+
return typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"));
|
|
1929
|
+
}
|
|
1930
|
+
async function enableWal(db) {
|
|
1931
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
1932
|
+
try {
|
|
1933
|
+
db.pragma("journal_mode = WAL");
|
|
1934
|
+
return;
|
|
1935
|
+
} catch (err) {
|
|
1936
|
+
if (attempt >= WAL_ATTEMPTS || !isLocked(err)) throw err;
|
|
1937
|
+
await (0, import_promises2.setTimeout)(WAL_RETRY_MS);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
var DEFAULT_REPLAY_CACHE_CAPACITY = 1e6;
|
|
1942
|
+
var EnvelopeReplayCache = class {
|
|
1943
|
+
capacity;
|
|
1944
|
+
ttlMs;
|
|
1945
|
+
_now;
|
|
1946
|
+
// Map iteration order is insertion order: first key = least recently used.
|
|
1947
|
+
_seen = /* @__PURE__ */ new Map();
|
|
1948
|
+
constructor(options) {
|
|
1949
|
+
const capacity = options?.capacity ?? 1e5;
|
|
1950
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
1951
|
+
throw new RangeError(
|
|
1952
|
+
`Replay cache capacity must be a positive integer, got ${capacity}`
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
this.capacity = capacity;
|
|
1956
|
+
this.ttlMs = options?.ttlMs ?? 3e5;
|
|
1957
|
+
this._now = options?.now ?? (() => Date.now());
|
|
1958
|
+
}
|
|
1959
|
+
get size() {
|
|
1960
|
+
return this._seen.size;
|
|
1961
|
+
}
|
|
1962
|
+
/** True if this pair was seen within the TTL. Records nothing. */
|
|
1963
|
+
has(fromAddress, messageId) {
|
|
1964
|
+
const seenAt = this._seen.get(this._key(fromAddress, messageId));
|
|
1965
|
+
return seenAt !== void 0 && this._now() - seenAt < this.ttlMs;
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* Return true if this (fromAddress, messageId) pair was already seen
|
|
1969
|
+
* within the TTL (a replay). Otherwise record it and return false.
|
|
1970
|
+
*/
|
|
1971
|
+
seenOrRecord(fromAddress, messageId) {
|
|
1972
|
+
const key = this._key(fromAddress, messageId);
|
|
1973
|
+
const now = this._now();
|
|
1974
|
+
const seenAt = this._seen.get(key);
|
|
1975
|
+
if (seenAt !== void 0 && now - seenAt < this.ttlMs) {
|
|
1976
|
+
this._seen.delete(key);
|
|
1977
|
+
this._seen.set(key, seenAt);
|
|
1978
|
+
return true;
|
|
1979
|
+
}
|
|
1980
|
+
this._seen.delete(key);
|
|
1981
|
+
this._seen.set(key, now);
|
|
1982
|
+
while (this._seen.size > this.capacity) {
|
|
1983
|
+
const oldest = this._seen.keys().next().value;
|
|
1984
|
+
this._seen.delete(oldest);
|
|
1985
|
+
}
|
|
1986
|
+
return false;
|
|
1987
|
+
}
|
|
1988
|
+
_key(fromAddress, messageId) {
|
|
1989
|
+
return JSON.stringify([fromAddress, messageId]);
|
|
1990
|
+
}
|
|
1991
|
+
};
|
|
1992
|
+
var PersistentReplayCache = class {
|
|
1993
|
+
capacity;
|
|
1994
|
+
_path;
|
|
1995
|
+
_now;
|
|
1996
|
+
_db = null;
|
|
1997
|
+
_opening = null;
|
|
1998
|
+
_count = 0;
|
|
1999
|
+
_lastPurge = 0;
|
|
2000
|
+
_createdAt = 0;
|
|
2001
|
+
// Used only while the file is full, for envelopes inside MAX_ENVELOPE_AGE.
|
|
2002
|
+
// One can be signed that far ahead of us and stay acceptable until that far
|
|
2003
|
+
// behind, so its id must last both.
|
|
2004
|
+
_overflow;
|
|
2005
|
+
/**
|
|
2006
|
+
* @param path - The SQLite file
|
|
2007
|
+
* @param options.now - The clock, in ms since the epoch
|
|
2008
|
+
*/
|
|
2009
|
+
constructor(path, options) {
|
|
2010
|
+
const capacity = options?.capacity ?? DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
2011
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
2012
|
+
throw new RangeError(
|
|
2013
|
+
`Replay cache capacity must be a positive integer, got ${capacity}`
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
this.capacity = capacity;
|
|
2017
|
+
this._path = path;
|
|
2018
|
+
this._now = options?.now ?? (() => Date.now());
|
|
2019
|
+
this._overflow = new EnvelopeReplayCache({
|
|
2020
|
+
capacity: 1e4,
|
|
2021
|
+
ttlMs: 2 * MAX_ENVELOPE_AGE * 1e3,
|
|
2022
|
+
now: this._now
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
/** Open the file now, rather than on first use. */
|
|
2026
|
+
async open() {
|
|
2027
|
+
await this._open();
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* Return true if the envelope must be dropped as a replay. Otherwise record
|
|
2031
|
+
* the pair and return false.
|
|
2032
|
+
*
|
|
2033
|
+
* @param signedAt - The envelope's signed time, in ms since the epoch. It
|
|
2034
|
+
* matters only when the cache is full.
|
|
2035
|
+
*/
|
|
2036
|
+
async seenOrRecord(fromAddress, messageId, signedAt) {
|
|
2037
|
+
const db = await this._open();
|
|
2038
|
+
const now = this._now();
|
|
2039
|
+
if (now - this._lastPurge >= PURGE_INTERVAL_MS) this._purge(db);
|
|
2040
|
+
if (this._overflow.has(fromAddress, messageId)) return true;
|
|
2041
|
+
if (this._count < this.capacity) {
|
|
2042
|
+
const { changes } = db.prepare(
|
|
2043
|
+
"INSERT OR IGNORE INTO seen_envelopes (from_address, message_id, seen_at) VALUES (?, ?, ?)"
|
|
2044
|
+
).run(fromAddress, messageId, now / 1e3);
|
|
2045
|
+
if (changes === 0) return true;
|
|
2046
|
+
this._count += 1;
|
|
2047
|
+
return false;
|
|
2048
|
+
}
|
|
2049
|
+
const known = db.prepare(
|
|
2050
|
+
"SELECT 1 FROM seen_envelopes WHERE from_address = ? AND message_id = ?"
|
|
2051
|
+
).get(fromAddress, messageId);
|
|
2052
|
+
if (known !== void 0) return true;
|
|
2053
|
+
if (Math.abs(now - signedAt) <= MAX_ENVELOPE_AGE * 1e3) {
|
|
2054
|
+
return this._overflow.seenOrRecord(fromAddress, messageId);
|
|
2055
|
+
}
|
|
2056
|
+
log.warn(
|
|
2057
|
+
`Replay cache full (${this._count} ids); refused envelope ${messageId} from ${fromAddress} signed ${Math.round((now - signedAt) / 1e3)}s ago`
|
|
2058
|
+
);
|
|
2059
|
+
return true;
|
|
2060
|
+
}
|
|
2061
|
+
/** When this cache file was first created, in ms since the epoch. */
|
|
2062
|
+
async createdAt() {
|
|
2063
|
+
await this._open();
|
|
2064
|
+
return this._createdAt;
|
|
2065
|
+
}
|
|
2066
|
+
/** True if an envelope signed at `signedAt` (ms) predates this cache. */
|
|
2067
|
+
async signedBeforeCache(signedAt) {
|
|
2068
|
+
return signedAt < await this.createdAt();
|
|
2069
|
+
}
|
|
2070
|
+
/** Number of ids currently held on disk. */
|
|
2071
|
+
async count() {
|
|
2072
|
+
const db = await this._open();
|
|
2073
|
+
return this._countRows(db);
|
|
2074
|
+
}
|
|
2075
|
+
/** Close the file; the next call opens it again. */
|
|
2076
|
+
async close() {
|
|
2077
|
+
await this._opening?.catch(() => void 0);
|
|
2078
|
+
this._db?.close();
|
|
2079
|
+
this._db = null;
|
|
2080
|
+
}
|
|
2081
|
+
_open() {
|
|
2082
|
+
if (this._db !== null) return Promise.resolve(this._db);
|
|
2083
|
+
this._opening ??= this._connect().finally(() => {
|
|
2084
|
+
this._opening = null;
|
|
2085
|
+
});
|
|
2086
|
+
return this._opening;
|
|
2087
|
+
}
|
|
2088
|
+
async _connect() {
|
|
2089
|
+
(0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(this._path), { recursive: true });
|
|
2090
|
+
const db = new import_better_sqlite32.default(this._path, { timeout: 3e4 });
|
|
2091
|
+
try {
|
|
2092
|
+
await enableWal(db);
|
|
2093
|
+
db.exec(SCHEMA);
|
|
2094
|
+
db.prepare(
|
|
2095
|
+
"INSERT OR IGNORE INTO replay_meta (key, value) VALUES ('created_at', ?)"
|
|
2096
|
+
).run(String(this._now() / 1e3));
|
|
2097
|
+
const row = db.prepare("SELECT value FROM replay_meta WHERE key = 'created_at'").get();
|
|
2098
|
+
this._createdAt = Number(row.value) * 1e3;
|
|
2099
|
+
this._purge(db);
|
|
2100
|
+
} catch (err) {
|
|
2101
|
+
db.close();
|
|
2102
|
+
throw err;
|
|
2103
|
+
}
|
|
2104
|
+
this._db = db;
|
|
2105
|
+
return db;
|
|
2106
|
+
}
|
|
2107
|
+
_purge(db) {
|
|
2108
|
+
const now = this._now();
|
|
2109
|
+
db.prepare("DELETE FROM seen_envelopes WHERE seen_at < ?").run(
|
|
2110
|
+
now / 1e3 - RETENTION_SECONDS
|
|
2111
|
+
);
|
|
2112
|
+
this._count = this._countRows(db);
|
|
2113
|
+
this._lastPurge = now;
|
|
2114
|
+
}
|
|
2115
|
+
_countRows(db) {
|
|
2116
|
+
const row = db.prepare("SELECT COUNT(*) AS n FROM seen_envelopes").get();
|
|
2117
|
+
return row.n;
|
|
2118
|
+
}
|
|
2119
|
+
};
|
|
2120
|
+
|
|
1686
2121
|
// src/sdk/agent.ts
|
|
2122
|
+
var RELAY_NO_MEMORY_MS = 6e4;
|
|
2123
|
+
function replayCacheCapacityFromEnv() {
|
|
2124
|
+
const raw = process.env["UAM_REPLAY_CACHE_CAPACITY"];
|
|
2125
|
+
if (raw === void 0 || raw === "") {
|
|
2126
|
+
return DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
2127
|
+
}
|
|
2128
|
+
const n = Number(raw);
|
|
2129
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
2130
|
+
log.warn(
|
|
2131
|
+
`Ignoring invalid UAM_REPLAY_CACHE_CAPACITY=${JSON.stringify(raw)}; using ${DEFAULT_REPLAY_CACHE_CAPACITY}`
|
|
2132
|
+
);
|
|
2133
|
+
return DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
2134
|
+
}
|
|
2135
|
+
return n;
|
|
2136
|
+
}
|
|
1687
2137
|
var Agent = class {
|
|
1688
2138
|
/** @internal */
|
|
1689
2139
|
_config;
|
|
@@ -1700,6 +2150,23 @@ var Agent = class {
|
|
|
1700
2150
|
_contactBook;
|
|
1701
2151
|
/** @internal */
|
|
1702
2152
|
_handshake;
|
|
2153
|
+
/**
|
|
2154
|
+
* @internal Wall clock in ms since the epoch, used by the inbound
|
|
2155
|
+
* freshness/expiry checks and the replay cache. Replaceable in tests.
|
|
2156
|
+
*/
|
|
2157
|
+
_clock = () => Date.now();
|
|
2158
|
+
/**
|
|
2159
|
+
* @internal Inbound replay cache, keyed by (from address, message id).
|
|
2160
|
+
* Kept on disk under the data dir, so it outlives restarts and is shared by
|
|
2161
|
+
* every process using it; capacity from UAM_REPLAY_CACHE_CAPACITY.
|
|
2162
|
+
*/
|
|
2163
|
+
_replayCache;
|
|
2164
|
+
/** Whether our relay has said it writes relay_meta. */
|
|
2165
|
+
_relayStamps = false;
|
|
2166
|
+
/** Until when, by _monotonic, to take the relay's last no as still true. */
|
|
2167
|
+
_relayNoUntil = 0;
|
|
2168
|
+
/** @internal Monotonic clock in ms. Replaceable in tests. */
|
|
2169
|
+
_monotonic = () => performance.now();
|
|
1703
2170
|
/**
|
|
1704
2171
|
* Create an Agent. No I/O happens here -- call connect() to initialize.
|
|
1705
2172
|
*/
|
|
@@ -1721,6 +2188,13 @@ var Agent = class {
|
|
|
1721
2188
|
this._contactBook,
|
|
1722
2189
|
this._config.trustPolicy
|
|
1723
2190
|
);
|
|
2191
|
+
this._replayCache = new PersistentReplayCache(
|
|
2192
|
+
(0, import_node_path5.join)(this._config.dataDir, "replay", "replay.db"),
|
|
2193
|
+
{
|
|
2194
|
+
capacity: replayCacheCapacityFromEnv(),
|
|
2195
|
+
now: () => this._clock()
|
|
2196
|
+
}
|
|
2197
|
+
);
|
|
1724
2198
|
}
|
|
1725
2199
|
// -- Properties ----------------------------------------------------------
|
|
1726
2200
|
/** The agent's full UAM address (e.g., 'myagent::youam.network'). */
|
|
@@ -1734,7 +2208,7 @@ var Agent = class {
|
|
|
1734
2208
|
}
|
|
1735
2209
|
/** UAM SDK version. */
|
|
1736
2210
|
get version() {
|
|
1737
|
-
return
|
|
2211
|
+
return SDK_VERSION;
|
|
1738
2212
|
}
|
|
1739
2213
|
/** The agent's public key (base64-encoded Ed25519 verify key). */
|
|
1740
2214
|
get publicKey() {
|
|
@@ -1771,10 +2245,19 @@ var Agent = class {
|
|
|
1771
2245
|
}
|
|
1772
2246
|
await sodiumReady;
|
|
1773
2247
|
this._keyManager.loadOrGenerate(this._config.name);
|
|
1774
|
-
|
|
2248
|
+
try {
|
|
2249
|
+
await this._replayCache.open();
|
|
2250
|
+
} catch (err) {
|
|
2251
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2252
|
+
log.warn(`Could not open the replay cache: ${reason}`);
|
|
2253
|
+
}
|
|
2254
|
+
const storedToken = this._keyManager.loadToken(
|
|
2255
|
+
this._config.name,
|
|
2256
|
+
this._config.relayUrl
|
|
2257
|
+
);
|
|
1775
2258
|
if (storedToken) {
|
|
1776
2259
|
this._token = storedToken;
|
|
1777
|
-
this.
|
|
2260
|
+
this._useAddress(await this._storedAddress());
|
|
1778
2261
|
} else if (this._autoRegister) {
|
|
1779
2262
|
await this._registerWithRelay();
|
|
1780
2263
|
} else {
|
|
@@ -1797,6 +2280,7 @@ var Agent = class {
|
|
|
1797
2280
|
*/
|
|
1798
2281
|
async close() {
|
|
1799
2282
|
this._contactBook.close();
|
|
2283
|
+
await this._replayCache.close();
|
|
1800
2284
|
if (this._transport) {
|
|
1801
2285
|
await this._transport.disconnect();
|
|
1802
2286
|
}
|
|
@@ -1817,7 +2301,8 @@ var Agent = class {
|
|
|
1817
2301
|
}
|
|
1818
2302
|
}
|
|
1819
2303
|
const recipientVk = await this._resolvePublicKey(toAddress);
|
|
1820
|
-
|
|
2304
|
+
const trustState = this._contactBook.getTrustState(toAddress);
|
|
2305
|
+
if (trustState !== "pinned" && trustState !== "verified" && trustState !== "trusted" && trustState !== "handshake-sent") {
|
|
1821
2306
|
await this._initiateHandshake(toAddress, recipientVk);
|
|
1822
2307
|
}
|
|
1823
2308
|
const envelope = await createEnvelope(
|
|
@@ -1848,7 +2333,10 @@ var Agent = class {
|
|
|
1848
2333
|
* Retrieve, decrypt, and verify pending messages.
|
|
1849
2334
|
*
|
|
1850
2335
|
* Returns a list of ReceivedMessage data objects.
|
|
1851
|
-
*
|
|
2336
|
+
* Drops messages with invalid signatures or decryption errors. A message
|
|
2337
|
+
* that fails processing for any other reason (malformed envelope, bad
|
|
2338
|
+
* handshake payload, ...) is logged and dropped; it never aborts the
|
|
2339
|
+
* rest of the batch.
|
|
1852
2340
|
*/
|
|
1853
2341
|
async inbox(limit = 50) {
|
|
1854
2342
|
await this._ensureConnected();
|
|
@@ -1856,7 +2344,15 @@ var Agent = class {
|
|
|
1856
2344
|
const rawMessages = await this._transport.receive(limit);
|
|
1857
2345
|
const result = [];
|
|
1858
2346
|
for (const raw of rawMessages) {
|
|
1859
|
-
|
|
2347
|
+
let msg;
|
|
2348
|
+
try {
|
|
2349
|
+
msg = await this._processInbound(raw);
|
|
2350
|
+
} catch (err) {
|
|
2351
|
+
log.warn(
|
|
2352
|
+
`Dropping inbound message ${String(raw["message_id"])} from ${String(raw["from"])}: ${err instanceof Error ? err.message : String(err)}`
|
|
2353
|
+
);
|
|
2354
|
+
continue;
|
|
2355
|
+
}
|
|
1860
2356
|
if (msg !== null) {
|
|
1861
2357
|
result.push(msg);
|
|
1862
2358
|
await this._sendReadReceipt(msg);
|
|
@@ -1886,13 +2382,14 @@ var Agent = class {
|
|
|
1886
2382
|
const cardDict = JSON.parse(entry.contactCard);
|
|
1887
2383
|
const card = contactCardFromDict(cardDict);
|
|
1888
2384
|
verifyContactCard(card);
|
|
2385
|
+
const senderVk = deserializeVerifyKey(card.publicKey);
|
|
2386
|
+
bindCardToEnvelope(card, { fromAddress: address }, senderVk);
|
|
1889
2387
|
this._contactBook.addContact(card.address, card.publicKey, {
|
|
1890
2388
|
displayName: card.displayName,
|
|
1891
2389
|
trustState: "trusted",
|
|
1892
2390
|
trustSource: "explicit-approval"
|
|
1893
2391
|
});
|
|
1894
2392
|
this._contactBook.removePending(address);
|
|
1895
|
-
const senderVk = deserializeVerifyKey(card.publicKey);
|
|
1896
2393
|
await this._handshake._sendAccept(this, address, senderVk);
|
|
1897
2394
|
}
|
|
1898
2395
|
/**
|
|
@@ -2029,6 +2526,10 @@ var Agent = class {
|
|
|
2029
2526
|
/**
|
|
2030
2527
|
* Resolve a recipient's public key, checking contact book first.
|
|
2031
2528
|
*
|
|
2529
|
+
* Pure lookup: never writes to the contact book. The contact row is
|
|
2530
|
+
* created by _initiateHandshake (as "handshake-sent") once the request
|
|
2531
|
+
* has gone out, giving the lifecycle no row -> handshake-sent -> pinned.
|
|
2532
|
+
*
|
|
2032
2533
|
* TOFU-01: Known contacts always use stored key (zero network I/O).
|
|
2033
2534
|
* TOFU-03: Race-condition double-check after network resolve.
|
|
2034
2535
|
* Raises KeyPinningError if a pinned contact's key doesn't match.
|
|
@@ -2052,9 +2553,6 @@ var Agent = class {
|
|
|
2052
2553
|
);
|
|
2053
2554
|
}
|
|
2054
2555
|
}
|
|
2055
|
-
this._contactBook.addContact(toAddress, resolvedPkStr, {
|
|
2056
|
-
trustState: "provisional"
|
|
2057
|
-
});
|
|
2058
2556
|
return deserializeVerifyKey(resolvedPkStr);
|
|
2059
2557
|
}
|
|
2060
2558
|
async _initiateHandshake(toAddress, recipientVk) {
|
|
@@ -2101,6 +2599,9 @@ var Agent = class {
|
|
|
2101
2599
|
}
|
|
2102
2600
|
throw err;
|
|
2103
2601
|
}
|
|
2602
|
+
if (!await this._passesInboundChecks(envelope, raw)) {
|
|
2603
|
+
return null;
|
|
2604
|
+
}
|
|
2104
2605
|
if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */ || envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */ || envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
|
|
2105
2606
|
return this._handshake.handleInbound(this, envelope, senderVk);
|
|
2106
2607
|
}
|
|
@@ -2135,6 +2636,102 @@ var Agent = class {
|
|
|
2135
2636
|
verified: true
|
|
2136
2637
|
});
|
|
2137
2638
|
}
|
|
2639
|
+
/**
|
|
2640
|
+
* Recipient, freshness, expiry and replay checks for a signature-verified
|
|
2641
|
+
* inbound envelope. Returns false (and logs) when it must be dropped.
|
|
2642
|
+
* Matches the Python SDK's _process_inbound checks.
|
|
2643
|
+
*/
|
|
2644
|
+
async _passesInboundChecks(envelope, raw) {
|
|
2645
|
+
const id = envelope.messageId;
|
|
2646
|
+
const from = envelope.fromAddress;
|
|
2647
|
+
if (this._address !== null && envelope.toAddress !== this._address) {
|
|
2648
|
+
log.warn(
|
|
2649
|
+
`Dropped envelope ${id} from ${from} addressed to ${envelope.toAddress} (we are ${this._address})`
|
|
2650
|
+
);
|
|
2651
|
+
return false;
|
|
2652
|
+
}
|
|
2653
|
+
const now = this._clock();
|
|
2654
|
+
const ts = Date.parse(envelope.timestamp);
|
|
2655
|
+
if (Number.isNaN(ts)) {
|
|
2656
|
+
log.warn(
|
|
2657
|
+
`Dropped envelope ${id} from ${from} with malformed timestamp ` + JSON.stringify(envelope.timestamp)
|
|
2658
|
+
);
|
|
2659
|
+
return false;
|
|
2660
|
+
}
|
|
2661
|
+
const ageSeconds = Math.abs(now - ts) / 1e3;
|
|
2662
|
+
if (ageSeconds > MAX_ENVELOPE_AGE && !await this._wasStored(raw, envelope.toAddress, ts, now)) {
|
|
2663
|
+
log.info(
|
|
2664
|
+
`Dropped stale envelope ${id} from ${from} (age=${Math.round(ageSeconds)}s > ${MAX_ENVELOPE_AGE}s)`
|
|
2665
|
+
);
|
|
2666
|
+
return false;
|
|
2667
|
+
}
|
|
2668
|
+
if (envelope.expires !== void 0 && envelope.expires !== null) {
|
|
2669
|
+
const exp = Date.parse(envelope.expires);
|
|
2670
|
+
if (Number.isNaN(exp)) {
|
|
2671
|
+
log.warn(
|
|
2672
|
+
`Dropped envelope ${id} from ${from} with malformed expires ` + JSON.stringify(envelope.expires)
|
|
2673
|
+
);
|
|
2674
|
+
return false;
|
|
2675
|
+
}
|
|
2676
|
+
if (now > exp) {
|
|
2677
|
+
log.info(
|
|
2678
|
+
`Dropped expired envelope ${id} from ${from} (expires=${envelope.expires})`
|
|
2679
|
+
);
|
|
2680
|
+
return false;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
if (await this._replayCache.seenOrRecord(from, id, ts)) {
|
|
2684
|
+
log.info(`Dropped replayed envelope ${id} from ${from}`);
|
|
2685
|
+
return false;
|
|
2686
|
+
}
|
|
2687
|
+
return true;
|
|
2688
|
+
}
|
|
2689
|
+
/** True if a message too old to be live is stored mail, not a replay. */
|
|
2690
|
+
async _wasStored(raw, toAddress, signedAt, now) {
|
|
2691
|
+
const relayDomain = toAddress.split("::").pop();
|
|
2692
|
+
if (!relayVouches(raw, signedAt, now, relayDomain)) return false;
|
|
2693
|
+
if (await this._replayCache.signedBeforeCache(signedAt)) return false;
|
|
2694
|
+
return this._relayStampsMail();
|
|
2695
|
+
}
|
|
2696
|
+
/**
|
|
2697
|
+
* True if our relay writes relay_meta, so a sender can't have.
|
|
2698
|
+
*
|
|
2699
|
+
* Asked only when a stale message carries a stamp. A yes is remembered for
|
|
2700
|
+
* good, a no for RELAY_NO_MEMORY_MS: a relay can be upgraded while this
|
|
2701
|
+
* agent runs. A failed lookup isn't remembered, because the relay has
|
|
2702
|
+
* already marked this mail delivered and the next message should get a
|
|
2703
|
+
* real answer.
|
|
2704
|
+
*/
|
|
2705
|
+
async _relayStampsMail() {
|
|
2706
|
+
if (this._relayStamps) return true;
|
|
2707
|
+
if (this._monotonic() < this._relayNoUntil) return false;
|
|
2708
|
+
try {
|
|
2709
|
+
const features = await this._fetchRelayFeatures();
|
|
2710
|
+
this._relayStamps = features.includes(RELAY_META_FEATURE);
|
|
2711
|
+
if (!this._relayStamps) {
|
|
2712
|
+
this._relayNoUntil = this._monotonic() + RELAY_NO_MEMORY_MS;
|
|
2713
|
+
}
|
|
2714
|
+
} catch (err) {
|
|
2715
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2716
|
+
log.warn(`Cannot read relay features: ${reason}`);
|
|
2717
|
+
}
|
|
2718
|
+
return this._relayStamps;
|
|
2719
|
+
}
|
|
2720
|
+
/** The relay's features; a body that doesn't list any is an empty list. */
|
|
2721
|
+
async _fetchRelayFeatures() {
|
|
2722
|
+
const resp = await fetch(`${this._config.relayUrl}/api/v1/versions`, {
|
|
2723
|
+
signal: AbortSignal.timeout(1e4)
|
|
2724
|
+
});
|
|
2725
|
+
if (!resp.ok) throw new UAMError(`Relay answered ${resp.status}`);
|
|
2726
|
+
const body = await resp.text();
|
|
2727
|
+
let data;
|
|
2728
|
+
try {
|
|
2729
|
+
data = JSON.parse(body);
|
|
2730
|
+
} catch {
|
|
2731
|
+
return [];
|
|
2732
|
+
}
|
|
2733
|
+
return Array.isArray(data?.features) ? data.features : [];
|
|
2734
|
+
}
|
|
2138
2735
|
/**
|
|
2139
2736
|
* Sweep expired pending handshakes and send receipt.failed (HAND-03).
|
|
2140
2737
|
*/
|
|
@@ -2187,7 +2784,8 @@ var Agent = class {
|
|
|
2187
2784
|
"receipt.read" /* RECEIPT_READ */,
|
|
2188
2785
|
new TextEncoder().encode(receiptPayload),
|
|
2189
2786
|
this._keyManager.signingKey,
|
|
2190
|
-
senderVk
|
|
2787
|
+
senderVk,
|
|
2788
|
+
{ replyTo: msg.messageId }
|
|
2191
2789
|
);
|
|
2192
2790
|
const wire = toWireDict(envelope);
|
|
2193
2791
|
await this._transport.send(wire);
|
|
@@ -2197,26 +2795,25 @@ var Agent = class {
|
|
|
2197
2795
|
/**
|
|
2198
2796
|
* Register with the relay server.
|
|
2199
2797
|
*
|
|
2200
|
-
* Calls POST /api/v1/register with agent name and public key.
|
|
2201
|
-
*
|
|
2798
|
+
* Calls POST /api/v1/register with agent name and public key. If the
|
|
2799
|
+
* address exists (409), repeats it with a proof that this agent holds the
|
|
2800
|
+
* key. Stores the returned API key on disk.
|
|
2202
2801
|
*/
|
|
2203
2802
|
async _registerWithRelay() {
|
|
2204
|
-
const
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
headers: { "Content-Type": "application/json" },
|
|
2210
|
-
body: JSON.stringify({
|
|
2211
|
-
agent_name: this._config.name,
|
|
2212
|
-
public_key: publicKeyStr
|
|
2213
|
-
})
|
|
2214
|
-
}
|
|
2215
|
-
);
|
|
2803
|
+
const body = {
|
|
2804
|
+
agent_name: this._config.name,
|
|
2805
|
+
public_key: serializeVerifyKey(this._keyManager.verifyKey)
|
|
2806
|
+
};
|
|
2807
|
+
let resp = await this._postRegistration(body);
|
|
2216
2808
|
if (resp.status === 409) {
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2809
|
+
const address2 = await this._addressOnRelay();
|
|
2810
|
+
resp = await this._postRegistration({
|
|
2811
|
+
...body,
|
|
2812
|
+
proof: this._registrationProof(address2)
|
|
2813
|
+
});
|
|
2814
|
+
if (resp.status === 409 || resp.status === 403) {
|
|
2815
|
+
throw this._ownershipNotProven(address2);
|
|
2816
|
+
}
|
|
2220
2817
|
}
|
|
2221
2818
|
if (!resp.ok) {
|
|
2222
2819
|
const text = await resp.text();
|
|
@@ -2225,20 +2822,153 @@ var Agent = class {
|
|
|
2225
2822
|
);
|
|
2226
2823
|
}
|
|
2227
2824
|
const data = await resp.json();
|
|
2228
|
-
|
|
2825
|
+
const address = data["address"];
|
|
2826
|
+
this._useAddress(address);
|
|
2229
2827
|
this._token = data["token"];
|
|
2230
|
-
|
|
2828
|
+
const { name } = this._config;
|
|
2829
|
+
const token = this._token;
|
|
2830
|
+
this._tryToSave("token", () => this._keyManager.saveToken(name, token));
|
|
2831
|
+
this._saveAddress(address);
|
|
2832
|
+
}
|
|
2833
|
+
/** This agent's address on the relay, for signing a proof. */
|
|
2834
|
+
async _addressOnRelay() {
|
|
2835
|
+
const { name, explicitRelayDomain, relayDomain, relayUrl } = this._config;
|
|
2836
|
+
if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
|
|
2837
|
+
const domain = await this._fetchRelayDomain();
|
|
2838
|
+
if (domain === null) return `${name}::${relayDomain}`;
|
|
2839
|
+
if (!this._relayMayUse(domain)) {
|
|
2840
|
+
throw new UAMError(
|
|
2841
|
+
`The relay at ${relayUrl} says its domain is ${domain}. Pass the domain option as ${JSON.stringify(domain)} if that is right.`
|
|
2842
|
+
);
|
|
2843
|
+
}
|
|
2844
|
+
return `${name}::${domain}`;
|
|
2845
|
+
}
|
|
2846
|
+
/**
|
|
2847
|
+
* True if the relay's URL fits `domain`, so a proof can be signed for it.
|
|
2848
|
+
*
|
|
2849
|
+
* A proof names an address on one relay, so it is only signed for a domain
|
|
2850
|
+
* this URL belongs to. A relay on this machine may use any domain.
|
|
2851
|
+
*/
|
|
2852
|
+
_relayMayUse(domain) {
|
|
2853
|
+
let host;
|
|
2854
|
+
try {
|
|
2855
|
+
host = new URL(this._config.relayUrl).hostname.toLowerCase();
|
|
2856
|
+
} catch {
|
|
2857
|
+
return false;
|
|
2858
|
+
}
|
|
2859
|
+
const wanted = domain.toLowerCase();
|
|
2860
|
+
return host === wanted || host.endsWith(`.${wanted}`) || host === "localhost" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
|
|
2861
|
+
}
|
|
2862
|
+
_postRegistration(body) {
|
|
2863
|
+
return fetch(`${this._config.relayUrl}/api/v1/register`, {
|
|
2864
|
+
method: "POST",
|
|
2865
|
+
headers: { "Content-Type": "application/json" },
|
|
2866
|
+
body: JSON.stringify(body)
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
2869
|
+
/**
|
|
2870
|
+
* Sign a statement that this agent holds the key for `address`.
|
|
2871
|
+
*
|
|
2872
|
+
* The signed string must stay byte-identical to the Python SDK's.
|
|
2873
|
+
*/
|
|
2874
|
+
_registrationProof(address) {
|
|
2875
|
+
const timestamp = utcTimestamp();
|
|
2876
|
+
const signature = signMessage(
|
|
2877
|
+
new TextEncoder().encode(`uam-register-v1
|
|
2878
|
+
${address}
|
|
2879
|
+
${timestamp}`),
|
|
2880
|
+
this._keyManager.signingKey
|
|
2881
|
+
);
|
|
2882
|
+
return { timestamp, signature };
|
|
2883
|
+
}
|
|
2884
|
+
_ownershipNotProven(address) {
|
|
2885
|
+
return new UAMError(
|
|
2886
|
+
`Address already registered, and this key could not prove ownership: ` + address
|
|
2887
|
+
);
|
|
2888
|
+
}
|
|
2889
|
+
/** Remember `address` for this relay, if the key folder allows it. */
|
|
2890
|
+
_saveAddress(address) {
|
|
2891
|
+
const { name, relayUrl } = this._config;
|
|
2892
|
+
this._tryToSave(
|
|
2893
|
+
"address",
|
|
2894
|
+
() => this._keyManager.saveAddress(name, address, relayUrl)
|
|
2895
|
+
);
|
|
2896
|
+
}
|
|
2897
|
+
/** Saving is best effort: a key folder we can't write doesn't stop us. */
|
|
2898
|
+
_tryToSave(what, save) {
|
|
2899
|
+
try {
|
|
2900
|
+
save();
|
|
2901
|
+
} catch (err) {
|
|
2902
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2903
|
+
log.warn(`Could not save the ${what}: ${reason}`);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
/** Take `address` as ours; its domain is this relay's real domain. */
|
|
2907
|
+
_useAddress(address) {
|
|
2908
|
+
this._address = address;
|
|
2909
|
+
const domain = parseAddress(address).domain;
|
|
2910
|
+
this._config.relayDomain = domain;
|
|
2911
|
+
if (this._resolver instanceof SmartResolver) {
|
|
2912
|
+
this._resolver.relayDomain = domain;
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2916
|
+
* The address the stored token belongs to.
|
|
2917
|
+
*
|
|
2918
|
+
* An explicit domain settles it. Otherwise the relay chooses the domain,
|
|
2919
|
+
* and it need not be the relay's hostname: use the address saved for this
|
|
2920
|
+
* relay, or ask the relay once and keep the answer.
|
|
2921
|
+
*/
|
|
2922
|
+
async _storedAddress() {
|
|
2923
|
+
const { name, explicitRelayDomain } = this._config;
|
|
2924
|
+
if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
|
|
2925
|
+
const saved = this._keyManager.loadAddress(name, this._config.relayUrl);
|
|
2926
|
+
if (saved) return saved;
|
|
2927
|
+
const domain = await this._fetchRelayDomain();
|
|
2928
|
+
if (domain === null) return `${name}::${this._config.relayDomain}`;
|
|
2929
|
+
const address = `${name}::${domain}`;
|
|
2930
|
+
this._saveAddress(address);
|
|
2931
|
+
return address;
|
|
2932
|
+
}
|
|
2933
|
+
/** Ask the relay for its domain; null if it cannot say. */
|
|
2934
|
+
async _fetchRelayDomain() {
|
|
2935
|
+
const url = `${this._config.relayUrl}/.well-known/uam-relay.json`;
|
|
2936
|
+
try {
|
|
2937
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(1e4) });
|
|
2938
|
+
if (!resp.ok) return null;
|
|
2939
|
+
const data = await resp.json();
|
|
2940
|
+
const domain = data["relay_domain"];
|
|
2941
|
+
if (typeof domain !== "string" || domain === "") return null;
|
|
2942
|
+
return parseAddress(`${this._config.name}::${domain}`).domain;
|
|
2943
|
+
} catch {
|
|
2944
|
+
return null;
|
|
2945
|
+
}
|
|
2231
2946
|
}
|
|
2232
2947
|
};
|
|
2233
2948
|
|
|
2234
2949
|
// src/sdk/webhook-verify.ts
|
|
2235
2950
|
var import_node_crypto3 = require("crypto");
|
|
2951
|
+
function verifyWebhookSignature(payload, signatureHeader, secret) {
|
|
2952
|
+
if (!signatureHeader.startsWith("sha256=")) {
|
|
2953
|
+
return false;
|
|
2954
|
+
}
|
|
2955
|
+
const expected = (0, import_node_crypto3.createHmac)("sha256", secret).update(payload).digest("hex");
|
|
2956
|
+
const received = signatureHeader.slice("sha256=".length);
|
|
2957
|
+
const expectedBuf = Buffer.from(expected, "utf-8");
|
|
2958
|
+
const receivedBuf = Buffer.from(received, "utf-8");
|
|
2959
|
+
if (expectedBuf.length !== receivedBuf.length) {
|
|
2960
|
+
return false;
|
|
2961
|
+
}
|
|
2962
|
+
return (0, import_node_crypto3.timingSafeEqual)(expectedBuf, receivedBuf);
|
|
2963
|
+
}
|
|
2236
2964
|
|
|
2237
2965
|
// src/sdk/index.ts
|
|
2238
2966
|
init_dns_verifier();
|
|
2239
2967
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2240
2968
|
0 && (module.exports = {
|
|
2241
2969
|
Agent,
|
|
2242
|
-
|
|
2970
|
+
ContactBook,
|
|
2971
|
+
protocol,
|
|
2972
|
+
verifyWebhookSignature
|
|
2243
2973
|
});
|
|
2244
2974
|
//# sourceMappingURL=index.cjs.map
|