@youam/sdk 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +7 -2
- package/dist/cli/index.cjs +1179 -195
- package/dist/index.cjs +1787 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +399 -19
- package/dist/index.d.ts +399 -19
- package/dist/index.js +1779 -133
- package/dist/index.js.map +1 -1
- package/package.json +10 -7
package/dist/index.cjs
CHANGED
|
@@ -60,11 +60,11 @@ function extractPublicKey(tags) {
|
|
|
60
60
|
}
|
|
61
61
|
return null;
|
|
62
62
|
}
|
|
63
|
-
async function queryUamTxt(
|
|
63
|
+
async function queryUamTxt(domain2, timeout) {
|
|
64
64
|
const controller = new AbortController();
|
|
65
65
|
const timer = timeout != null ? setTimeout(() => controller.abort(), timeout) : void 0;
|
|
66
66
|
try {
|
|
67
|
-
const records = await (0, import_promises.resolveTxt)(`_uam.${
|
|
67
|
+
const records = await (0, import_promises.resolveTxt)(`_uam.${domain2}`);
|
|
68
68
|
const results = [];
|
|
69
69
|
for (const parts of records) {
|
|
70
70
|
const txtValue = parts.join("");
|
|
@@ -79,8 +79,8 @@ async function queryUamTxt(domain, timeout) {
|
|
|
79
79
|
if (timer != null) clearTimeout(timer);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
async function resolveKeyViaHttps(agentName,
|
|
83
|
-
const url = `https://${
|
|
82
|
+
async function resolveKeyViaHttps(agentName, domain2, timeout) {
|
|
83
|
+
const url = `https://${domain2}/.well-known/uam.json`;
|
|
84
84
|
try {
|
|
85
85
|
const resp = await fetch(url, {
|
|
86
86
|
signal: AbortSignal.timeout(timeout ?? 1e4)
|
|
@@ -115,10 +115,19 @@ var init_dns_verifier = __esm({
|
|
|
115
115
|
var index_exports = {};
|
|
116
116
|
__export(index_exports, {
|
|
117
117
|
Agent: () => Agent,
|
|
118
|
-
|
|
118
|
+
ContactBook: () => ContactBook,
|
|
119
|
+
PENDING_NOTE: () => PENDING_NOTE,
|
|
120
|
+
SEND_STATUSES: () => SEND_STATUSES,
|
|
121
|
+
pendingNote: () => pendingNote,
|
|
122
|
+
protocol: () => protocol_exports,
|
|
123
|
+
supports: () => supports,
|
|
124
|
+
verifyWebhookSignature: () => verifyWebhookSignature
|
|
119
125
|
});
|
|
120
126
|
module.exports = __toCommonJS(index_exports);
|
|
121
127
|
|
|
128
|
+
// src/sdk/agent.ts
|
|
129
|
+
var import_node_path5 = require("path");
|
|
130
|
+
|
|
122
131
|
// src/protocol/index.ts
|
|
123
132
|
var protocol_exports = {};
|
|
124
133
|
__export(protocol_exports, {
|
|
@@ -127,31 +136,43 @@ __export(protocol_exports, {
|
|
|
127
136
|
EnvelopeTooLargeError: () => EnvelopeTooLargeError,
|
|
128
137
|
InvalidAddressError: () => InvalidAddressError,
|
|
129
138
|
InvalidContactCardError: () => InvalidContactCardError,
|
|
139
|
+
InvalidDidKeyError: () => InvalidDidKeyError,
|
|
130
140
|
InvalidEnvelopeError: () => InvalidEnvelopeError,
|
|
131
141
|
KeyPinningError: () => KeyPinningError,
|
|
142
|
+
MAX_ENVELOPE_AGE: () => MAX_ENVELOPE_AGE,
|
|
132
143
|
MAX_ENVELOPE_SIZE: () => MAX_ENVELOPE_SIZE,
|
|
133
144
|
MessageType: () => MessageType,
|
|
145
|
+
SendRefusedError: () => SendRefusedError,
|
|
134
146
|
SignatureError: () => SignatureError,
|
|
135
147
|
SignatureVerificationError: () => SignatureVerificationError,
|
|
148
|
+
SignedObjectError: () => SignedObjectError,
|
|
136
149
|
UAMError: () => UAMError,
|
|
137
150
|
UAM_VERSION: () => UAM_VERSION,
|
|
151
|
+
WebAuthnError: () => WebAuthnError,
|
|
152
|
+
approvalChallenge: () => approvalChallenge,
|
|
138
153
|
b64Decode: () => b64Decode,
|
|
139
154
|
b64Encode: () => b64Encode,
|
|
155
|
+
canonical: () => canonical,
|
|
140
156
|
canonicalize: () => canonicalize,
|
|
141
157
|
contactCardFromDict: () => contactCardFromDict,
|
|
142
158
|
contactCardToDict: () => contactCardToDict,
|
|
143
159
|
createContactCard: () => createContactCard,
|
|
144
160
|
createEnvelope: () => createEnvelope,
|
|
161
|
+
credentialDid: () => credentialDid,
|
|
162
|
+
decodeDidKey: () => decodeDidKey,
|
|
145
163
|
decryptPayload: () => decryptPayload,
|
|
146
164
|
decryptPayloadAnonymous: () => decryptPayloadAnonymous,
|
|
147
165
|
deserializeSigningKey: () => deserializeSigningKey,
|
|
148
166
|
deserializeVerifyKey: () => deserializeVerifyKey,
|
|
167
|
+
encodeDidKey: () => encodeDidKey,
|
|
149
168
|
encryptPayload: () => encryptPayload,
|
|
150
169
|
encryptPayloadAnonymous: () => encryptPayloadAnonymous,
|
|
151
170
|
fromWireDict: () => fromWireDict,
|
|
152
171
|
generateKeypair: () => generateKeypair,
|
|
153
172
|
generateNonce: () => generateNonce,
|
|
173
|
+
objectHash: () => objectHash,
|
|
154
174
|
parseAddress: () => parseAddress,
|
|
175
|
+
parseBody: () => parseBody,
|
|
155
176
|
publicKeyFingerprint: () => publicKeyFingerprint,
|
|
156
177
|
serializeSigningKey: () => serializeSigningKey,
|
|
157
178
|
serializeVerifyKey: () => serializeVerifyKey,
|
|
@@ -160,14 +181,18 @@ __export(protocol_exports, {
|
|
|
160
181
|
toWireDict: () => toWireDict,
|
|
161
182
|
utcTimestamp: () => utcTimestamp,
|
|
162
183
|
validateEnvelopeSize: () => validateEnvelopeSize,
|
|
184
|
+
validateSignedObject: () => validate,
|
|
185
|
+
verifyAssertion: () => verifyAssertion,
|
|
163
186
|
verifyContactCard: () => verifyContactCard,
|
|
164
187
|
verifyEnvelope: () => verifyEnvelope,
|
|
188
|
+
verifyRegistration: () => verifyRegistration,
|
|
165
189
|
verifySignature: () => verifySignature
|
|
166
190
|
});
|
|
167
191
|
|
|
168
192
|
// src/protocol/types.ts
|
|
169
193
|
var UAM_VERSION = "0.1";
|
|
170
194
|
var MAX_ENVELOPE_SIZE = 65536;
|
|
195
|
+
var MAX_ENVELOPE_AGE = 300;
|
|
171
196
|
var MessageType = /* @__PURE__ */ ((MessageType2) => {
|
|
172
197
|
MessageType2["MESSAGE"] = "message";
|
|
173
198
|
MessageType2["HANDSHAKE_REQUEST"] = "handshake.request";
|
|
@@ -255,6 +280,18 @@ var KeyPinningError = class _KeyPinningError extends UAMError {
|
|
|
255
280
|
Object.setPrototypeOf(this, _KeyPinningError.prototype);
|
|
256
281
|
}
|
|
257
282
|
};
|
|
283
|
+
var SendRefusedError = class extends UAMError {
|
|
284
|
+
messageId;
|
|
285
|
+
code;
|
|
286
|
+
detail;
|
|
287
|
+
constructor(messageId, code, detail) {
|
|
288
|
+
super(`Relay refused message ${messageId}: [${code}] ${detail ?? ""}`.trimEnd());
|
|
289
|
+
this.name = "SendRefusedError";
|
|
290
|
+
this.messageId = messageId;
|
|
291
|
+
this.code = code;
|
|
292
|
+
this.detail = detail;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
258
295
|
|
|
259
296
|
// src/protocol/address.ts
|
|
260
297
|
var ADDRESS_RE = /^(?<agent>[a-z0-9][a-z0-9_-]{0,62}[a-z0-9]|[a-z0-9])::(?<domain>[a-z0-9](?:[a-z0-9.-]{0,253}[a-z0-9])?)$/;
|
|
@@ -381,11 +418,11 @@ function encryptPayload(plaintext, senderSigningKey, recipientVerifyKey) {
|
|
|
381
418
|
try {
|
|
382
419
|
const senderCurve = import_libsodium_wrappers.default.crypto_sign_ed25519_sk_to_curve25519(senderSigningKey);
|
|
383
420
|
const recipientCurve = import_libsodium_wrappers.default.crypto_sign_ed25519_pk_to_curve25519(recipientVerifyKey);
|
|
384
|
-
const
|
|
385
|
-
const encrypted = import_libsodium_wrappers.default.crypto_box_easy(plaintext,
|
|
386
|
-
const combined = new Uint8Array(
|
|
387
|
-
combined.set(
|
|
388
|
-
combined.set(encrypted,
|
|
421
|
+
const nonce2 = import_libsodium_wrappers.default.randombytes_buf(import_libsodium_wrappers.default.crypto_box_NONCEBYTES);
|
|
422
|
+
const encrypted = import_libsodium_wrappers.default.crypto_box_easy(plaintext, nonce2, recipientCurve, senderCurve);
|
|
423
|
+
const combined = new Uint8Array(nonce2.length + encrypted.length);
|
|
424
|
+
combined.set(nonce2, 0);
|
|
425
|
+
combined.set(encrypted, nonce2.length);
|
|
389
426
|
return b64Encode(combined);
|
|
390
427
|
} catch (err) {
|
|
391
428
|
if (err instanceof EncryptionError) throw err;
|
|
@@ -395,11 +432,11 @@ function encryptPayload(plaintext, senderSigningKey, recipientVerifyKey) {
|
|
|
395
432
|
function decryptPayload(ciphertextB64, recipientSigningKey, senderVerifyKey) {
|
|
396
433
|
try {
|
|
397
434
|
const combined = b64Decode(ciphertextB64);
|
|
398
|
-
const
|
|
435
|
+
const nonce2 = combined.slice(0, import_libsodium_wrappers.default.crypto_box_NONCEBYTES);
|
|
399
436
|
const ciphertext = combined.slice(import_libsodium_wrappers.default.crypto_box_NONCEBYTES);
|
|
400
437
|
const recipientCurve = import_libsodium_wrappers.default.crypto_sign_ed25519_sk_to_curve25519(recipientSigningKey);
|
|
401
438
|
const senderCurve = import_libsodium_wrappers.default.crypto_sign_ed25519_pk_to_curve25519(senderVerifyKey);
|
|
402
|
-
return import_libsodium_wrappers.default.crypto_box_open_easy(ciphertext,
|
|
439
|
+
return import_libsodium_wrappers.default.crypto_box_open_easy(ciphertext, nonce2, senderCurve, recipientCurve);
|
|
403
440
|
} catch (err) {
|
|
404
441
|
if (err instanceof DecryptionError) throw err;
|
|
405
442
|
throw new DecryptionError(err instanceof Error ? err.message : String(err));
|
|
@@ -507,7 +544,7 @@ async function createEnvelope(fromAddress, toAddress, messageType, payloadPlaint
|
|
|
507
544
|
parseAddress(fromAddress);
|
|
508
545
|
parseAddress(toAddress);
|
|
509
546
|
const messageId = (0, import_node_crypto2.randomUUID)();
|
|
510
|
-
const
|
|
547
|
+
const nonce2 = generateNonce();
|
|
511
548
|
const timestamp = utcTimestamp();
|
|
512
549
|
let encryptedPayload;
|
|
513
550
|
if (typeValue === "handshake.request" /* HANDSHAKE_REQUEST */) {
|
|
@@ -526,7 +563,7 @@ async function createEnvelope(fromAddress, toAddress, messageType, payloadPlaint
|
|
|
526
563
|
toAddress,
|
|
527
564
|
timestamp,
|
|
528
565
|
type: typeValue,
|
|
529
|
-
nonce,
|
|
566
|
+
nonce: nonce2,
|
|
530
567
|
payload: encryptedPayload,
|
|
531
568
|
signature: "",
|
|
532
569
|
threadId: options?.threadId,
|
|
@@ -545,7 +582,7 @@ async function createEnvelope(fromAddress, toAddress, messageType, payloadPlaint
|
|
|
545
582
|
toAddress,
|
|
546
583
|
timestamp,
|
|
547
584
|
type: typeValue,
|
|
548
|
-
nonce,
|
|
585
|
+
nonce: nonce2,
|
|
549
586
|
payload: encryptedPayload,
|
|
550
587
|
signature,
|
|
551
588
|
threadId: options?.threadId,
|
|
@@ -637,8 +674,8 @@ function contactCardFromDict(d, options) {
|
|
|
637
674
|
}
|
|
638
675
|
return card;
|
|
639
676
|
}
|
|
640
|
-
async function createContactCard(
|
|
641
|
-
parseAddress(
|
|
677
|
+
async function createContactCard(address2, displayName, relay, signingKey, options) {
|
|
678
|
+
parseAddress(address2);
|
|
642
679
|
const verifyKey = signingKey.slice(32);
|
|
643
680
|
const pubKeyB64 = serializeVerifyKey(verifyKey);
|
|
644
681
|
const fp = publicKeyFingerprint(verifyKey);
|
|
@@ -648,7 +685,7 @@ async function createContactCard(address, displayName, relay, signingKey, option
|
|
|
648
685
|
];
|
|
649
686
|
const tempCard = {
|
|
650
687
|
version: UAM_VERSION,
|
|
651
|
-
address,
|
|
688
|
+
address: address2,
|
|
652
689
|
displayName,
|
|
653
690
|
description: options?.description,
|
|
654
691
|
system: options?.system,
|
|
@@ -665,7 +702,7 @@ async function createContactCard(address, displayName, relay, signingKey, option
|
|
|
665
702
|
const signature = signMessage(canonicalize(signable), signingKey);
|
|
666
703
|
return Object.freeze({
|
|
667
704
|
version: UAM_VERSION,
|
|
668
|
-
address,
|
|
705
|
+
address: address2,
|
|
669
706
|
displayName,
|
|
670
707
|
description: options?.description,
|
|
671
708
|
system: options?.system,
|
|
@@ -692,10 +729,786 @@ function verifyContactCard(card) {
|
|
|
692
729
|
verifySignature(canonicalize(signable), card.signature, vk);
|
|
693
730
|
}
|
|
694
731
|
|
|
732
|
+
// src/protocol/signed.ts
|
|
733
|
+
var import_node_crypto3 = require("crypto");
|
|
734
|
+
|
|
735
|
+
// src/protocol/didkey.ts
|
|
736
|
+
var B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
737
|
+
var PREFIX = {
|
|
738
|
+
ed25519: new Uint8Array([237, 1]),
|
|
739
|
+
p256: new Uint8Array([128, 36])
|
|
740
|
+
};
|
|
741
|
+
var LENGTH = { ed25519: 32, p256: 33 };
|
|
742
|
+
var InvalidDidKeyError = class extends UAMError {
|
|
743
|
+
constructor(message) {
|
|
744
|
+
super(message);
|
|
745
|
+
this.name = "InvalidDidKeyError";
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
function b58encode(data) {
|
|
749
|
+
let n = 0n;
|
|
750
|
+
for (const byte of data) n = n << 8n | BigInt(byte);
|
|
751
|
+
let out = "";
|
|
752
|
+
while (n > 0n) {
|
|
753
|
+
out = B58[Number(n % 58n)] + out;
|
|
754
|
+
n /= 58n;
|
|
755
|
+
}
|
|
756
|
+
let zeros = 0;
|
|
757
|
+
while (zeros < data.length && data[zeros] === 0) zeros += 1;
|
|
758
|
+
return "1".repeat(zeros) + out;
|
|
759
|
+
}
|
|
760
|
+
function b58decode(text) {
|
|
761
|
+
let n = 0n;
|
|
762
|
+
for (const c of text) {
|
|
763
|
+
const i = B58.indexOf(c);
|
|
764
|
+
if (i < 0) throw new InvalidDidKeyError(`not base58btc: ${JSON.stringify(c)}`);
|
|
765
|
+
n = n * 58n + BigInt(i);
|
|
766
|
+
}
|
|
767
|
+
const body = [];
|
|
768
|
+
while (n > 0n) {
|
|
769
|
+
body.unshift(Number(n & 0xffn));
|
|
770
|
+
n >>= 8n;
|
|
771
|
+
}
|
|
772
|
+
let zeros = 0;
|
|
773
|
+
while (zeros < text.length && text[zeros] === "1") zeros += 1;
|
|
774
|
+
return new Uint8Array([...new Array(zeros).fill(0), ...body]);
|
|
775
|
+
}
|
|
776
|
+
function checkKey(kind, key) {
|
|
777
|
+
if (key.length !== LENGTH[kind]) {
|
|
778
|
+
throw new InvalidDidKeyError(`${kind} key must be ${LENGTH[kind]} bytes`);
|
|
779
|
+
}
|
|
780
|
+
if (kind === "p256" && key[0] !== 2 && key[0] !== 3) {
|
|
781
|
+
throw new InvalidDidKeyError("P-256 key must be a compressed point");
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function encodeDidKey(kind, publicKey) {
|
|
785
|
+
if (!(kind in PREFIX)) throw new InvalidDidKeyError(`unsupported key type: ${kind}`);
|
|
786
|
+
checkKey(kind, publicKey);
|
|
787
|
+
return "did:key:z" + b58encode(new Uint8Array([...PREFIX[kind], ...publicKey]));
|
|
788
|
+
}
|
|
789
|
+
function decodeDidKey(did2) {
|
|
790
|
+
if (typeof did2 !== "string" || !did2.startsWith("did:key:z")) {
|
|
791
|
+
throw new InvalidDidKeyError("not a did:key with base58btc multibase");
|
|
792
|
+
}
|
|
793
|
+
const raw = b58decode(did2.slice("did:key:z".length));
|
|
794
|
+
for (const kind of Object.keys(PREFIX)) {
|
|
795
|
+
const prefix = PREFIX[kind];
|
|
796
|
+
if (raw.length >= prefix.length && prefix.every((b, i) => raw[i] === b)) {
|
|
797
|
+
const key = raw.slice(prefix.length);
|
|
798
|
+
checkKey(kind, key);
|
|
799
|
+
return { kind, key };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
throw new InvalidDidKeyError("unsupported key type");
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/protocol/signed.ts
|
|
806
|
+
var MAX_INT = 2 ** 53 - 1;
|
|
807
|
+
var GRANT_MAX_DAYS = 90;
|
|
808
|
+
var INVITE_MAX_DAYS = 7;
|
|
809
|
+
var SignedObjectError = class extends UAMError {
|
|
810
|
+
constructor(message) {
|
|
811
|
+
super(message);
|
|
812
|
+
this.name = "SignedObjectError";
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
var has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
816
|
+
var Parser = class {
|
|
817
|
+
constructor(s) {
|
|
818
|
+
this.s = s;
|
|
819
|
+
}
|
|
820
|
+
i = 0;
|
|
821
|
+
fail(why) {
|
|
822
|
+
throw new SignedObjectError(`not JSON or outside the profile at ${this.i}: ${why}`);
|
|
823
|
+
}
|
|
824
|
+
ws() {
|
|
825
|
+
while (this.i < this.s.length && " \n\r".includes(this.s[this.i])) this.i += 1;
|
|
826
|
+
}
|
|
827
|
+
document() {
|
|
828
|
+
this.ws();
|
|
829
|
+
if (this.s[this.i] !== "{") this.fail("a body must be a JSON object");
|
|
830
|
+
const value = this.value();
|
|
831
|
+
this.ws();
|
|
832
|
+
if (this.i !== this.s.length) this.fail("trailing characters");
|
|
833
|
+
return value;
|
|
834
|
+
}
|
|
835
|
+
value() {
|
|
836
|
+
this.ws();
|
|
837
|
+
const c = this.s[this.i];
|
|
838
|
+
if (c === "{") return this.object();
|
|
839
|
+
if (c === "[") return this.array();
|
|
840
|
+
if (c === '"') return this.string();
|
|
841
|
+
if (c === "-" || c !== void 0 && c >= "0" && c <= "9") return this.number();
|
|
842
|
+
for (const [word, v] of [["true", true], ["false", false]]) {
|
|
843
|
+
if (this.s.startsWith(word, this.i)) {
|
|
844
|
+
this.i += word.length;
|
|
845
|
+
return v;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (this.s.startsWith("null", this.i)) this.fail("null is not allowed");
|
|
849
|
+
return this.fail("unexpected character");
|
|
850
|
+
}
|
|
851
|
+
object() {
|
|
852
|
+
this.i += 1;
|
|
853
|
+
const out = /* @__PURE__ */ Object.create(null);
|
|
854
|
+
this.ws();
|
|
855
|
+
if (this.s[this.i] === "}") {
|
|
856
|
+
this.i += 1;
|
|
857
|
+
return out;
|
|
858
|
+
}
|
|
859
|
+
for (; ; ) {
|
|
860
|
+
this.ws();
|
|
861
|
+
if (this.s[this.i] !== '"') this.fail("expected a key");
|
|
862
|
+
const key = this.string();
|
|
863
|
+
if (has(out, key)) this.fail(`duplicate key: ${JSON.stringify(key)}`);
|
|
864
|
+
this.ws();
|
|
865
|
+
if (this.s[this.i] !== ":") this.fail("expected ':'");
|
|
866
|
+
this.i += 1;
|
|
867
|
+
Object.defineProperty(out, key, {
|
|
868
|
+
value: this.value(),
|
|
869
|
+
enumerable: true,
|
|
870
|
+
writable: true,
|
|
871
|
+
configurable: true
|
|
872
|
+
});
|
|
873
|
+
this.ws();
|
|
874
|
+
if (this.s[this.i] === ",") {
|
|
875
|
+
this.i += 1;
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
if (this.s[this.i] === "}") {
|
|
879
|
+
this.i += 1;
|
|
880
|
+
return out;
|
|
881
|
+
}
|
|
882
|
+
this.fail("expected ',' or '}'");
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
array() {
|
|
886
|
+
this.i += 1;
|
|
887
|
+
const out = [];
|
|
888
|
+
this.ws();
|
|
889
|
+
if (this.s[this.i] === "]") {
|
|
890
|
+
this.i += 1;
|
|
891
|
+
return out;
|
|
892
|
+
}
|
|
893
|
+
for (; ; ) {
|
|
894
|
+
out.push(this.value());
|
|
895
|
+
this.ws();
|
|
896
|
+
if (this.s[this.i] === ",") {
|
|
897
|
+
this.i += 1;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (this.s[this.i] === "]") {
|
|
901
|
+
this.i += 1;
|
|
902
|
+
return out;
|
|
903
|
+
}
|
|
904
|
+
this.fail("expected ',' or ']'");
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
string() {
|
|
908
|
+
this.i += 1;
|
|
909
|
+
let out = "";
|
|
910
|
+
for (; ; ) {
|
|
911
|
+
const c = this.s[this.i];
|
|
912
|
+
if (c === void 0) this.fail("unterminated string");
|
|
913
|
+
this.i += 1;
|
|
914
|
+
if (c === '"') break;
|
|
915
|
+
if (c === "\\") {
|
|
916
|
+
const e = this.s[this.i];
|
|
917
|
+
this.i += 1;
|
|
918
|
+
const simple = {
|
|
919
|
+
'"': '"',
|
|
920
|
+
"\\": "\\",
|
|
921
|
+
"/": "/",
|
|
922
|
+
b: "\b",
|
|
923
|
+
f: "\f",
|
|
924
|
+
n: "\n",
|
|
925
|
+
r: "\r",
|
|
926
|
+
t: " "
|
|
927
|
+
};
|
|
928
|
+
if (e !== void 0 && has(simple, e)) {
|
|
929
|
+
out += simple[e];
|
|
930
|
+
} else if (e === "u") {
|
|
931
|
+
const hex = this.s.slice(this.i, this.i + 4);
|
|
932
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) this.fail("bad \\u escape");
|
|
933
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
934
|
+
this.i += 4;
|
|
935
|
+
} else {
|
|
936
|
+
this.fail("bad escape");
|
|
937
|
+
}
|
|
938
|
+
} else {
|
|
939
|
+
if (c < " ") this.fail("control character in string");
|
|
940
|
+
out += c;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
checkString(out);
|
|
944
|
+
return out;
|
|
945
|
+
}
|
|
946
|
+
number() {
|
|
947
|
+
const m = /^-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?/.exec(this.s.slice(this.i));
|
|
948
|
+
if (!m) return this.fail("bad number");
|
|
949
|
+
const text = m[0];
|
|
950
|
+
if (m[2] !== void 0 || m[3] !== void 0) this.fail(`floats are not allowed: ${text}`);
|
|
951
|
+
if (text === "-0") this.fail("-0 is not allowed");
|
|
952
|
+
const big = BigInt(text);
|
|
953
|
+
if (big > BigInt(MAX_INT) || big < -BigInt(MAX_INT)) this.fail(`integer out of range: ${text}`);
|
|
954
|
+
this.i += text.length;
|
|
955
|
+
return Number(big);
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
function parseBody(text) {
|
|
959
|
+
let s;
|
|
960
|
+
if (typeof text === "string") {
|
|
961
|
+
s = text;
|
|
962
|
+
} else {
|
|
963
|
+
try {
|
|
964
|
+
s = new TextDecoder("utf-8", { fatal: true }).decode(text);
|
|
965
|
+
} catch {
|
|
966
|
+
throw new SignedObjectError("body is not UTF-8");
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return new Parser(s).document();
|
|
970
|
+
}
|
|
971
|
+
function checkString(value) {
|
|
972
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
973
|
+
const code = value.charCodeAt(i);
|
|
974
|
+
if (code < 32 || code > 126) {
|
|
975
|
+
throw new SignedObjectError("strings must be printable ASCII");
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
function isPlainObject(value) {
|
|
980
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
981
|
+
const proto = Object.getPrototypeOf(value);
|
|
982
|
+
return proto === null || proto === Object.prototype;
|
|
983
|
+
}
|
|
984
|
+
function encode(value) {
|
|
985
|
+
if (value === true) return "true";
|
|
986
|
+
if (value === false) return "false";
|
|
987
|
+
if (typeof value === "number") {
|
|
988
|
+
if (!Number.isSafeInteger(value) || Object.is(value, -0)) {
|
|
989
|
+
throw new SignedObjectError(`not an integer in range: ${value}`);
|
|
990
|
+
}
|
|
991
|
+
return String(value);
|
|
992
|
+
}
|
|
993
|
+
if (typeof value === "string") {
|
|
994
|
+
checkString(value);
|
|
995
|
+
return '"' + value.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
|
|
996
|
+
}
|
|
997
|
+
if (Array.isArray(value)) return "[" + value.map(encode).join(",") + "]";
|
|
998
|
+
if (isPlainObject(value)) {
|
|
999
|
+
return "{" + Object.keys(value).sort().map((key) => {
|
|
1000
|
+
checkString(key);
|
|
1001
|
+
return encode(key) + ":" + encode(value[key]);
|
|
1002
|
+
}).join(",") + "}";
|
|
1003
|
+
}
|
|
1004
|
+
if (value === null) throw new SignedObjectError("null is not allowed");
|
|
1005
|
+
throw new SignedObjectError(`not allowed: ${typeof value}`);
|
|
1006
|
+
}
|
|
1007
|
+
function canonical(body) {
|
|
1008
|
+
if (!isPlainObject(body)) throw new SignedObjectError("a body must be a JSON object");
|
|
1009
|
+
return new TextEncoder().encode(encode(body));
|
|
1010
|
+
}
|
|
1011
|
+
function hashCanonical(body) {
|
|
1012
|
+
return (0, import_node_crypto3.createHash)("sha256").update(`UAM-${String(body["type"])}-v1
|
|
1013
|
+
`).update(canonical(body)).digest("base64url");
|
|
1014
|
+
}
|
|
1015
|
+
function objectHash(body) {
|
|
1016
|
+
validate(body);
|
|
1017
|
+
return hashCanonical(body);
|
|
1018
|
+
}
|
|
1019
|
+
var TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/;
|
|
1020
|
+
var HASH_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
1021
|
+
var OWNER_ID_RE = /^[A-Za-z0-9_-]{22}$/;
|
|
1022
|
+
var NONCE_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
1023
|
+
var SECRET_RE = /^[A-Za-z0-9_-]{22}$/;
|
|
1024
|
+
var DOMAIN_RE = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
1025
|
+
function fail(path, why) {
|
|
1026
|
+
throw new SignedObjectError(`${path}: ${why}`);
|
|
1027
|
+
}
|
|
1028
|
+
var string_ = (v, p) => {
|
|
1029
|
+
if (typeof v !== "string" || v.length === 0) fail(p, "must be a non-empty string");
|
|
1030
|
+
};
|
|
1031
|
+
var label = (v, p) => {
|
|
1032
|
+
string_(v, p);
|
|
1033
|
+
if (v.length > 64) fail(p, "at most 64 characters");
|
|
1034
|
+
if (v.includes('"')) fail(p, 'must not contain "');
|
|
1035
|
+
};
|
|
1036
|
+
var matching = (re, what) => (v, p) => {
|
|
1037
|
+
if (typeof v !== "string" || !re.test(v)) fail(p, `must be ${what}`);
|
|
1038
|
+
};
|
|
1039
|
+
var hash = matching(HASH_RE, "a hash (43 base64url characters)");
|
|
1040
|
+
var ownerId = matching(OWNER_ID_RE, "an owner id (22 base64url characters)");
|
|
1041
|
+
var nonce = matching(NONCE_RE, "a nonce (16-64 base64url characters)");
|
|
1042
|
+
var secret = matching(SECRET_RE, "a secret (22 base64url characters)");
|
|
1043
|
+
var domain = matching(DOMAIN_RE, "a lowercase domain");
|
|
1044
|
+
function parseTime(value) {
|
|
1045
|
+
const m = TIME_RE.exec(value);
|
|
1046
|
+
if (!m) return null;
|
|
1047
|
+
const [y, mo, d, h, mi, s] = m.slice(1).map(Number);
|
|
1048
|
+
const t = Date.UTC(y, mo - 1, d, h, mi, s);
|
|
1049
|
+
const back = new Date(t);
|
|
1050
|
+
if (back.getUTCFullYear() !== y || back.getUTCMonth() !== mo - 1 || back.getUTCDate() !== d || back.getUTCHours() !== h || back.getUTCMinutes() !== mi || back.getUTCSeconds() !== s) {
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
return t;
|
|
1054
|
+
}
|
|
1055
|
+
var time = (v, p) => {
|
|
1056
|
+
if (typeof v !== "string" || !TIME_RE.test(v)) fail(p, "must be YYYY-MM-DDTHH:MM:SSZ");
|
|
1057
|
+
if (parseTime(v) === null) fail(p, "not a real time");
|
|
1058
|
+
};
|
|
1059
|
+
var bool = (v, p) => {
|
|
1060
|
+
if (typeof v !== "boolean") fail(p, "must be true or false");
|
|
1061
|
+
};
|
|
1062
|
+
var constant = (expected) => (v, p) => {
|
|
1063
|
+
if (v !== expected) fail(p, `must be ${JSON.stringify(expected)}`);
|
|
1064
|
+
};
|
|
1065
|
+
var oneOf = (...options) => (v, p) => {
|
|
1066
|
+
if (typeof v !== "string" || !options.includes(v)) fail(p, `must be one of ${options.join(", ")}`);
|
|
1067
|
+
};
|
|
1068
|
+
var uint = (v, p) => {
|
|
1069
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0) {
|
|
1070
|
+
fail(p, "must be a non-negative integer");
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
var address = (v, p) => {
|
|
1074
|
+
if (typeof v !== "string") fail(p, "must be an address");
|
|
1075
|
+
let full;
|
|
1076
|
+
try {
|
|
1077
|
+
full = parseAddress(v).full;
|
|
1078
|
+
} catch {
|
|
1079
|
+
return fail(p, "must be an address");
|
|
1080
|
+
}
|
|
1081
|
+
if (full !== v) fail(p, "must be an address in canonical (lowercase) form");
|
|
1082
|
+
};
|
|
1083
|
+
var did = (kind) => (v, p) => {
|
|
1084
|
+
let found;
|
|
1085
|
+
try {
|
|
1086
|
+
found = decodeDidKey(v).kind;
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
return fail(p, err instanceof Error ? err.message : String(err));
|
|
1089
|
+
}
|
|
1090
|
+
if (found !== kind) fail(p, `must be a ${kind} did:key`);
|
|
1091
|
+
};
|
|
1092
|
+
var list = (item, minLen = 1, maxLen = 64) => (v, p) => {
|
|
1093
|
+
if (!Array.isArray(v) || v.length < minLen || v.length > maxLen) {
|
|
1094
|
+
fail(p, `must be a list of ${minLen}-${maxLen} items`);
|
|
1095
|
+
}
|
|
1096
|
+
v.forEach((entry, i) => item(entry, `${p}[${i}]`));
|
|
1097
|
+
};
|
|
1098
|
+
var object = (required, optional = {}) => (v, p) => {
|
|
1099
|
+
if (!isPlainObject(v)) fail(p, "must be an object");
|
|
1100
|
+
for (const key of Object.keys(v)) {
|
|
1101
|
+
if (!has(required, key) && !has(optional, key)) fail(`${p}.${key}`, "unknown member");
|
|
1102
|
+
}
|
|
1103
|
+
for (const [key, member] of Object.entries(required)) {
|
|
1104
|
+
if (!has(v, key)) fail(`${p}.${key}`, "missing");
|
|
1105
|
+
member(v[key], `${p}.${key}`);
|
|
1106
|
+
}
|
|
1107
|
+
for (const [key, member] of Object.entries(optional)) {
|
|
1108
|
+
if (has(v, key)) member(v[key], `${p}.${key}`);
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
var location = (v, p) => {
|
|
1112
|
+
if (v !== "shared") address(v, p);
|
|
1113
|
+
};
|
|
1114
|
+
var access = object({ actions: list(oneOf("message"), 1, 1), locations: list(location) });
|
|
1115
|
+
var ownerRef = object({ relay: domain, ownerId, keysHash: hash });
|
|
1116
|
+
var ownerRefNoKeys = object({ relay: domain, ownerId });
|
|
1117
|
+
var passkey = did("p256");
|
|
1118
|
+
var agentKey = did("ed25519");
|
|
1119
|
+
var keysOwner = object({ relay: domain }, { ownerId });
|
|
1120
|
+
var EXT = { ext: () => void 0, crit: () => void 0 };
|
|
1121
|
+
function checkExt(body) {
|
|
1122
|
+
if (has(body, "ext") && !isPlainObject(body["ext"])) fail("ext", "must be an object");
|
|
1123
|
+
if (has(body, "crit")) {
|
|
1124
|
+
const crit = body["crit"];
|
|
1125
|
+
if (!has(body, "ext")) fail("crit", "needs ext");
|
|
1126
|
+
if (!Array.isArray(crit) || !crit.every((k) => typeof k === "string")) {
|
|
1127
|
+
fail("crit", "must be a list of ext keys");
|
|
1128
|
+
}
|
|
1129
|
+
for (const key of crit) {
|
|
1130
|
+
if (!has(body["ext"], key)) fail(`crit.${key}`, "not in ext");
|
|
1131
|
+
fail(`crit.${key}`, "unknown critical extension");
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
function span(body, from, to) {
|
|
1136
|
+
return parseTime(body[to]) - parseTime(body[from]);
|
|
1137
|
+
}
|
|
1138
|
+
var DAY = 24 * 60 * 60 * 1e3;
|
|
1139
|
+
var SCHEMAS = {
|
|
1140
|
+
UamOwnerKeys(body) {
|
|
1141
|
+
object(
|
|
1142
|
+
{
|
|
1143
|
+
type: constant("UamOwnerKeys"),
|
|
1144
|
+
v: constant(1),
|
|
1145
|
+
owner: keysOwner,
|
|
1146
|
+
seq: uint,
|
|
1147
|
+
keys: list(object({ id: passkey, rpId: domain, backupEligible: bool, label }), 1, 16)
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
prev: hash,
|
|
1151
|
+
removed: list(object({ id: passkey, reason: oneOf("removed", "compromised") }), 1, 64),
|
|
1152
|
+
recovery: bool,
|
|
1153
|
+
...EXT
|
|
1154
|
+
}
|
|
1155
|
+
)(body, "$");
|
|
1156
|
+
const owner = body["owner"];
|
|
1157
|
+
if (body["seq"] === 0) {
|
|
1158
|
+
if (has(owner, "ownerId") || has(body, "prev")) fail("$", "entry 0 has no owner.ownerId and no prev");
|
|
1159
|
+
} else if (!has(owner, "ownerId") || !has(body, "prev")) {
|
|
1160
|
+
fail("$", "a later entry needs owner.ownerId and prev");
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
UamOwnerLink(body) {
|
|
1164
|
+
object({
|
|
1165
|
+
type: constant("UamOwnerLink"),
|
|
1166
|
+
v: constant(1),
|
|
1167
|
+
owner: ownerRef,
|
|
1168
|
+
agent: address,
|
|
1169
|
+
agentKey,
|
|
1170
|
+
relay: domain,
|
|
1171
|
+
via: oneOf("setup", "link"),
|
|
1172
|
+
shared: bool,
|
|
1173
|
+
effectiveAt: time,
|
|
1174
|
+
validFrom: time,
|
|
1175
|
+
nonce
|
|
1176
|
+
}, { ...EXT })(body, "$");
|
|
1177
|
+
},
|
|
1178
|
+
UamOwnerUnlink(body) {
|
|
1179
|
+
object({
|
|
1180
|
+
type: constant("UamOwnerUnlink"),
|
|
1181
|
+
v: constant(1),
|
|
1182
|
+
owner: ownerRef,
|
|
1183
|
+
agent: address,
|
|
1184
|
+
agentKey,
|
|
1185
|
+
reason: oneOf("owner", "admin", "relink", "authority", "lapsed"),
|
|
1186
|
+
validFrom: time,
|
|
1187
|
+
nonce
|
|
1188
|
+
}, { ...EXT })(body, "$");
|
|
1189
|
+
},
|
|
1190
|
+
UamInvite(body) {
|
|
1191
|
+
object(
|
|
1192
|
+
{
|
|
1193
|
+
type: constant("UamInvite"),
|
|
1194
|
+
v: constant(1),
|
|
1195
|
+
inviter: ownerRef,
|
|
1196
|
+
offer: access,
|
|
1197
|
+
ask: access,
|
|
1198
|
+
secretHash: hash,
|
|
1199
|
+
mode: oneOf("preapproved", "confirm"),
|
|
1200
|
+
maxUses: constant(1),
|
|
1201
|
+
validFrom: time,
|
|
1202
|
+
redeemBy: time,
|
|
1203
|
+
nonce
|
|
1204
|
+
},
|
|
1205
|
+
{ emailHash: hash, ...EXT }
|
|
1206
|
+
)(body, "$");
|
|
1207
|
+
if (body["mode"] === "preapproved" && !has(body, "emailHash")) {
|
|
1208
|
+
fail("$.emailHash", "a pre-approved invite names the invitee's email");
|
|
1209
|
+
}
|
|
1210
|
+
const d = span(body, "validFrom", "redeemBy");
|
|
1211
|
+
if (!(d > 0 && d <= INVITE_MAX_DAYS * DAY)) {
|
|
1212
|
+
fail("$.redeemBy", `must be after validFrom, within ${INVITE_MAX_DAYS} days`);
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
UamRedemption(body) {
|
|
1216
|
+
object({
|
|
1217
|
+
type: constant("UamRedemption"),
|
|
1218
|
+
v: constant(1),
|
|
1219
|
+
invite: hash,
|
|
1220
|
+
secret,
|
|
1221
|
+
invitee: ownerRef,
|
|
1222
|
+
issuer: passkey,
|
|
1223
|
+
createdAt: time,
|
|
1224
|
+
nonce
|
|
1225
|
+
}, { ...EXT })(body, "$");
|
|
1226
|
+
},
|
|
1227
|
+
UamContactGrant(body) {
|
|
1228
|
+
object({
|
|
1229
|
+
type: constant("UamContactGrant"),
|
|
1230
|
+
v: constant(1),
|
|
1231
|
+
issuer: passkey,
|
|
1232
|
+
issuerOwner: ownerRefNoKeys,
|
|
1233
|
+
credentialSubject: subject,
|
|
1234
|
+
authorization_details: list(object({
|
|
1235
|
+
type: constant("uam_contact"),
|
|
1236
|
+
actions: list(oneOf("message"), 1, 1),
|
|
1237
|
+
locations: list(location)
|
|
1238
|
+
}), 1, 8),
|
|
1239
|
+
via: oneOf("intro_email", "intro_code", "intro_qr", "request", "carried_over"),
|
|
1240
|
+
enforcer: domain,
|
|
1241
|
+
validFrom: time,
|
|
1242
|
+
validUntil: time,
|
|
1243
|
+
nonce
|
|
1244
|
+
}, { ...EXT })(body, "$");
|
|
1245
|
+
const d = span(body, "validFrom", "validUntil");
|
|
1246
|
+
if (!(d > 0 && d <= GRANT_MAX_DAYS * DAY)) {
|
|
1247
|
+
fail("$.validUntil", `must be after validFrom, within ${GRANT_MAX_DAYS} days`);
|
|
1248
|
+
}
|
|
1249
|
+
},
|
|
1250
|
+
// Who may message an agent, as its owner approved it (P2e). Only a change
|
|
1251
|
+
// that widens it is signed; narrowing needs no passkey.
|
|
1252
|
+
UamAgentPolicy(body) {
|
|
1253
|
+
object({
|
|
1254
|
+
type: constant("UamAgentPolicy"),
|
|
1255
|
+
v: constant(1),
|
|
1256
|
+
owner: ownerRef,
|
|
1257
|
+
agent: address,
|
|
1258
|
+
agentKey,
|
|
1259
|
+
policy: oneOf("open", "contacts"),
|
|
1260
|
+
sameOwner: bool,
|
|
1261
|
+
validFrom: time,
|
|
1262
|
+
nonce
|
|
1263
|
+
}, { ...EXT })(body, "$");
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
var SUBJECTS = {
|
|
1267
|
+
owner: object({ kind: constant("owner"), owner: ownerRef }),
|
|
1268
|
+
invitee: object({ kind: constant("invitee"), invite: hash }),
|
|
1269
|
+
agent: object({ kind: constant("agent"), id: agentKey, address })
|
|
1270
|
+
};
|
|
1271
|
+
function subject(value, path) {
|
|
1272
|
+
const kind = isPlainObject(value) ? value["kind"] : void 0;
|
|
1273
|
+
if (typeof kind !== "string" || !has(SUBJECTS, kind)) {
|
|
1274
|
+
fail(`${path}.kind`, `must be one of ${Object.keys(SUBJECTS).join(", ")}`);
|
|
1275
|
+
}
|
|
1276
|
+
SUBJECTS[kind](value, path);
|
|
1277
|
+
}
|
|
1278
|
+
function validate(body) {
|
|
1279
|
+
if (!isPlainObject(body)) throw new SignedObjectError("a body must be a JSON object");
|
|
1280
|
+
encode(body);
|
|
1281
|
+
const type = body["type"];
|
|
1282
|
+
if (typeof type !== "string" || !has(SCHEMAS, type)) {
|
|
1283
|
+
throw new SignedObjectError(`unknown type: ${JSON.stringify(type)}`);
|
|
1284
|
+
}
|
|
1285
|
+
SCHEMAS[type](body);
|
|
1286
|
+
checkExt(body);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// src/protocol/webauthn.ts
|
|
1290
|
+
var import_node_crypto4 = require("crypto");
|
|
1291
|
+
var import_p256 = require("@noble/curves/p256");
|
|
1292
|
+
var ES256 = -7;
|
|
1293
|
+
var UP = 1;
|
|
1294
|
+
var UV = 4;
|
|
1295
|
+
var BE = 8;
|
|
1296
|
+
var AT = 64;
|
|
1297
|
+
var ED = 128;
|
|
1298
|
+
var WebAuthnError = class extends UAMError {
|
|
1299
|
+
constructor(message) {
|
|
1300
|
+
super(message);
|
|
1301
|
+
this.name = "WebAuthnError";
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
function credentialDid(credential) {
|
|
1305
|
+
return encodeDidKey("p256", credential.publicKey);
|
|
1306
|
+
}
|
|
1307
|
+
var sha256 = (...parts) => {
|
|
1308
|
+
const h = (0, import_node_crypto4.createHash)("sha256");
|
|
1309
|
+
for (const part of parts) h.update(part);
|
|
1310
|
+
return new Uint8Array(h.digest());
|
|
1311
|
+
};
|
|
1312
|
+
var equal = (a, b) => a.length === b.length && a.every((byte, i) => byte === b[i]);
|
|
1313
|
+
function cbor(data, start) {
|
|
1314
|
+
let i = start;
|
|
1315
|
+
if (i >= data.length) throw new WebAuthnError("CBOR: truncated");
|
|
1316
|
+
const first = data[i];
|
|
1317
|
+
const major = first >> 5;
|
|
1318
|
+
const info = first & 31;
|
|
1319
|
+
i += 1;
|
|
1320
|
+
if (major === 7) {
|
|
1321
|
+
if (info === 20) return [false, i];
|
|
1322
|
+
if (info === 21) return [true, i];
|
|
1323
|
+
throw new WebAuthnError("CBOR: unsupported simple value or float");
|
|
1324
|
+
}
|
|
1325
|
+
let n;
|
|
1326
|
+
if (info < 24) {
|
|
1327
|
+
n = info;
|
|
1328
|
+
} else if (info >= 24 && info <= 27) {
|
|
1329
|
+
const size = 1 << info - 24;
|
|
1330
|
+
if (i + size > data.length) throw new WebAuthnError("CBOR: truncated");
|
|
1331
|
+
let big = 0n;
|
|
1332
|
+
for (let k = 0; k < size; k += 1) big = big << 8n | BigInt(data[i + k]);
|
|
1333
|
+
if (big > BigInt(Number.MAX_SAFE_INTEGER)) throw new WebAuthnError("CBOR: number too large");
|
|
1334
|
+
n = Number(big);
|
|
1335
|
+
i += size;
|
|
1336
|
+
} else {
|
|
1337
|
+
throw new WebAuthnError("CBOR: indefinite lengths are not supported");
|
|
1338
|
+
}
|
|
1339
|
+
if (major === 0) return [n, i];
|
|
1340
|
+
if (major === 1) return [-1 - n, i];
|
|
1341
|
+
if (major === 2 || major === 3) {
|
|
1342
|
+
if (i + n > data.length) throw new WebAuthnError("CBOR: truncated");
|
|
1343
|
+
const raw = data.slice(i, i + n);
|
|
1344
|
+
i += n;
|
|
1345
|
+
if (major === 2) return [raw, i];
|
|
1346
|
+
try {
|
|
1347
|
+
return [new TextDecoder("utf-8", { fatal: true }).decode(raw), i];
|
|
1348
|
+
} catch {
|
|
1349
|
+
throw new WebAuthnError("CBOR: text is not UTF-8");
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
if (major === 4) {
|
|
1353
|
+
const items = [];
|
|
1354
|
+
for (let k = 0; k < n; k += 1) {
|
|
1355
|
+
const [item, next] = cbor(data, i);
|
|
1356
|
+
items.push(item);
|
|
1357
|
+
i = next;
|
|
1358
|
+
}
|
|
1359
|
+
return [items, i];
|
|
1360
|
+
}
|
|
1361
|
+
if (major === 5) {
|
|
1362
|
+
const out = /* @__PURE__ */ new Map();
|
|
1363
|
+
for (let k = 0; k < n; k += 1) {
|
|
1364
|
+
const [key, afterKey] = cbor(data, i);
|
|
1365
|
+
if (typeof key !== "number" && typeof key !== "string") {
|
|
1366
|
+
throw new WebAuthnError("CBOR: map keys must be integers or text");
|
|
1367
|
+
}
|
|
1368
|
+
if (out.has(key)) throw new WebAuthnError("CBOR: duplicate map key");
|
|
1369
|
+
const [value, afterValue] = cbor(data, afterKey);
|
|
1370
|
+
out.set(key, value);
|
|
1371
|
+
i = afterValue;
|
|
1372
|
+
}
|
|
1373
|
+
return [out, i];
|
|
1374
|
+
}
|
|
1375
|
+
throw new WebAuthnError("CBOR: tags are not supported");
|
|
1376
|
+
}
|
|
1377
|
+
function cborWhole(data) {
|
|
1378
|
+
const [value, end] = cbor(data, 0);
|
|
1379
|
+
if (end !== data.length) throw new WebAuthnError("CBOR: trailing bytes");
|
|
1380
|
+
return value;
|
|
1381
|
+
}
|
|
1382
|
+
function checkClientData(raw, kind, challenge, origin) {
|
|
1383
|
+
let data;
|
|
1384
|
+
try {
|
|
1385
|
+
data = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw));
|
|
1386
|
+
} catch {
|
|
1387
|
+
throw new WebAuthnError("clientDataJSON is not JSON");
|
|
1388
|
+
}
|
|
1389
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
1390
|
+
throw new WebAuthnError("clientDataJSON is not an object");
|
|
1391
|
+
}
|
|
1392
|
+
const d = data;
|
|
1393
|
+
if (d["type"] !== kind) throw new WebAuthnError(`clientData.type must be ${kind}`);
|
|
1394
|
+
if (d["challenge"] !== Buffer.from(challenge).toString("base64url")) {
|
|
1395
|
+
throw new WebAuthnError("clientData.challenge doesn't match");
|
|
1396
|
+
}
|
|
1397
|
+
if (d["origin"] !== origin) throw new WebAuthnError("clientData.origin doesn't match");
|
|
1398
|
+
if ("crossOrigin" in d && d["crossOrigin"] !== false) {
|
|
1399
|
+
throw new WebAuthnError("cross-origin ceremonies are refused");
|
|
1400
|
+
}
|
|
1401
|
+
if ("topOrigin" in d) throw new WebAuthnError("ceremonies in a frame are refused");
|
|
1402
|
+
}
|
|
1403
|
+
function checkAuthData(data, rpId) {
|
|
1404
|
+
if (data.length < 37) throw new WebAuthnError("authenticatorData too short");
|
|
1405
|
+
if (!equal(data.slice(0, 32), sha256(new TextEncoder().encode(rpId)))) {
|
|
1406
|
+
throw new WebAuthnError("rpIdHash doesn't match");
|
|
1407
|
+
}
|
|
1408
|
+
const flags = data[32];
|
|
1409
|
+
if (!(flags & UP)) throw new WebAuthnError("user not present");
|
|
1410
|
+
if (!(flags & UV)) throw new WebAuthnError("user not verified");
|
|
1411
|
+
const count = new DataView(data.buffer, data.byteOffset + 33, 4).getUint32(0, false);
|
|
1412
|
+
return { flags, count };
|
|
1413
|
+
}
|
|
1414
|
+
function point(bytes) {
|
|
1415
|
+
try {
|
|
1416
|
+
return import_p256.p256.ProjectivePoint.fromHex(bytes);
|
|
1417
|
+
} catch {
|
|
1418
|
+
throw new WebAuthnError("not a P-256 point");
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
function verifyRegistration(args) {
|
|
1422
|
+
checkClientData(args.clientDataJSON, "webauthn.create", args.challenge, args.origin);
|
|
1423
|
+
const att = cborWhole(args.attestationObject);
|
|
1424
|
+
if (!(att instanceof Map) || att.size !== 3 || !att.has("fmt") || !att.has("attStmt") || !att.has("authData")) {
|
|
1425
|
+
throw new WebAuthnError("attestationObject must have fmt, attStmt and authData");
|
|
1426
|
+
}
|
|
1427
|
+
const stmt = att.get("attStmt");
|
|
1428
|
+
if (att.get("fmt") !== "none" || !(stmt instanceof Map) || stmt.size !== 0) {
|
|
1429
|
+
throw new WebAuthnError("only attestation format 'none' is accepted");
|
|
1430
|
+
}
|
|
1431
|
+
const data = att.get("authData");
|
|
1432
|
+
if (!(data instanceof Uint8Array)) throw new WebAuthnError("authData must be bytes");
|
|
1433
|
+
const { flags, count } = checkAuthData(data, args.rpId);
|
|
1434
|
+
if (!(flags & AT)) throw new WebAuthnError("no attested credential data");
|
|
1435
|
+
if (data.length < 55) throw new WebAuthnError("attested credential data too short");
|
|
1436
|
+
const idLen = data[53] << 8 | data[54];
|
|
1437
|
+
const credentialId = data.slice(55, 55 + idLen);
|
|
1438
|
+
if (credentialId.length !== idLen) throw new WebAuthnError("credential id truncated");
|
|
1439
|
+
const [cose, end] = cbor(data, 55 + idLen);
|
|
1440
|
+
if (!(flags & ED) && end !== data.length) {
|
|
1441
|
+
throw new WebAuthnError("trailing bytes after the credential key");
|
|
1442
|
+
}
|
|
1443
|
+
if (!(cose instanceof Map) || cose.get(1) !== 2 || cose.get(3) !== ES256 || cose.get(-1) !== 1) {
|
|
1444
|
+
throw new WebAuthnError("only ES256 (P-256) passkeys are accepted");
|
|
1445
|
+
}
|
|
1446
|
+
const x = cose.get(-2);
|
|
1447
|
+
const y = cose.get(-3);
|
|
1448
|
+
if (!(x instanceof Uint8Array) || !(y instanceof Uint8Array) || x.length !== 32 || y.length !== 32) {
|
|
1449
|
+
throw new WebAuthnError("bad P-256 coordinates");
|
|
1450
|
+
}
|
|
1451
|
+
const key = point(new Uint8Array([4, ...x, ...y]));
|
|
1452
|
+
return {
|
|
1453
|
+
id: credentialId,
|
|
1454
|
+
alg: ES256,
|
|
1455
|
+
publicKey: key.toRawBytes(true),
|
|
1456
|
+
backupEligible: Boolean(flags & BE),
|
|
1457
|
+
signCount: count
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
function verifyAssertion(credential, args) {
|
|
1461
|
+
if (credential.alg !== ES256) throw new WebAuthnError("only ES256 (P-256) passkeys are accepted");
|
|
1462
|
+
checkClientData(args.clientDataJSON, "webauthn.get", args.challenge, args.origin);
|
|
1463
|
+
const { flags, count } = checkAuthData(args.authenticatorData, args.rpId);
|
|
1464
|
+
if (Boolean(flags & BE) !== credential.backupEligible) {
|
|
1465
|
+
throw new WebAuthnError("backup eligibility changed since registration");
|
|
1466
|
+
}
|
|
1467
|
+
const key = point(credential.publicKey);
|
|
1468
|
+
const digest = sha256(args.authenticatorData, sha256(args.clientDataJSON));
|
|
1469
|
+
let ok = false;
|
|
1470
|
+
try {
|
|
1471
|
+
ok = import_p256.p256.verify(args.signature, digest, key.toRawBytes(false), {
|
|
1472
|
+
format: "der",
|
|
1473
|
+
lowS: false,
|
|
1474
|
+
prehash: false
|
|
1475
|
+
});
|
|
1476
|
+
} catch {
|
|
1477
|
+
ok = false;
|
|
1478
|
+
}
|
|
1479
|
+
if (!ok) throw new WebAuthnError("bad signature");
|
|
1480
|
+
const checkCounter = args.checkCounter ?? true;
|
|
1481
|
+
if (checkCounter && (count || credential.signCount) && count <= credential.signCount) {
|
|
1482
|
+
throw new WebAuthnError("sign count didn't go up: the passkey may have been cloned");
|
|
1483
|
+
}
|
|
1484
|
+
return count;
|
|
1485
|
+
}
|
|
1486
|
+
function approvalChallenge(items, screen) {
|
|
1487
|
+
if (items.length === 0) throw new WebAuthnError("an approval covers at least one object");
|
|
1488
|
+
const body = {
|
|
1489
|
+
set: items.map(([t, h]) => ({ t, h })),
|
|
1490
|
+
screen: (0, import_node_crypto4.createHash)("sha256").update(screen, "utf8").digest("hex")
|
|
1491
|
+
};
|
|
1492
|
+
let data;
|
|
1493
|
+
try {
|
|
1494
|
+
data = canonical(body);
|
|
1495
|
+
} catch (err) {
|
|
1496
|
+
if (err instanceof SignedObjectError) throw new WebAuthnError(`bad approval set: ${err.message}`);
|
|
1497
|
+
throw err;
|
|
1498
|
+
}
|
|
1499
|
+
return sha256(new TextEncoder().encode("UAM-APPROVAL-v1\n"), data);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// src/version.ts
|
|
1503
|
+
var SDK_VERSION = "0.4.0";
|
|
1504
|
+
|
|
695
1505
|
// src/sdk/config.ts
|
|
696
1506
|
var import_node_os = require("os");
|
|
697
1507
|
var import_node_path = require("path");
|
|
698
1508
|
var DEFAULT_RELAY_URL = "https://relay.youam.network";
|
|
1509
|
+
function trimRelayUrl(url) {
|
|
1510
|
+
return url.replace(/\/+$/, "");
|
|
1511
|
+
}
|
|
699
1512
|
var VALID_POLICIES = /* @__PURE__ */ new Set([
|
|
700
1513
|
"auto-accept",
|
|
701
1514
|
"approval-required",
|
|
@@ -712,9 +1525,13 @@ var SDKConfig = class {
|
|
|
712
1525
|
transportType;
|
|
713
1526
|
trustPolicy;
|
|
714
1527
|
relayDomain;
|
|
1528
|
+
/** The domain the caller chose, if any; null when only derived. */
|
|
1529
|
+
explicitRelayDomain;
|
|
715
1530
|
constructor(options) {
|
|
716
1531
|
this.name = options.name;
|
|
717
|
-
this.relayUrl =
|
|
1532
|
+
this.relayUrl = trimRelayUrl(
|
|
1533
|
+
options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL
|
|
1534
|
+
);
|
|
718
1535
|
if (options.relayWsUrl) {
|
|
719
1536
|
this.relayWsUrl = options.relayWsUrl;
|
|
720
1537
|
} else {
|
|
@@ -725,6 +1542,7 @@ var SDKConfig = class {
|
|
|
725
1542
|
this.relayWsUrl = wsUrl;
|
|
726
1543
|
}
|
|
727
1544
|
const envDomain = process.env["UAM_RELAY_DOMAIN"];
|
|
1545
|
+
this.explicitRelayDomain = envDomain || options.relayDomain || null;
|
|
728
1546
|
if (envDomain) {
|
|
729
1547
|
this.relayDomain = envDomain;
|
|
730
1548
|
} else if (options.relayDomain) {
|
|
@@ -758,6 +1576,27 @@ var SDKConfig = class {
|
|
|
758
1576
|
}
|
|
759
1577
|
};
|
|
760
1578
|
|
|
1579
|
+
// src/sdk/freshness.ts
|
|
1580
|
+
var RELAY_META_FEATURE = "relay_meta";
|
|
1581
|
+
var STORED_MAIL_MAX_AGE = 7 * 86400;
|
|
1582
|
+
var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
1583
|
+
function relayVouches(raw, signedAt, now, relayDomain) {
|
|
1584
|
+
const meta = raw["relay_meta"];
|
|
1585
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
|
|
1586
|
+
return false;
|
|
1587
|
+
}
|
|
1588
|
+
const stamp = meta;
|
|
1589
|
+
if (stamp["v"] !== 1 || stamp["relay"] !== relayDomain) return false;
|
|
1590
|
+
if (stamp["stored"] !== true) return false;
|
|
1591
|
+
const received = stamp["received_at"];
|
|
1592
|
+
if (typeof received !== "string" || !ISO_TIME.test(received)) return false;
|
|
1593
|
+
const receivedAt = Date.parse(received);
|
|
1594
|
+
if (Number.isNaN(receivedAt)) return false;
|
|
1595
|
+
const window = MAX_ENVELOPE_AGE * 1e3;
|
|
1596
|
+
const age = now - signedAt;
|
|
1597
|
+
return Math.abs(receivedAt - signedAt) <= window && age >= 0 && age <= STORED_MAIL_MAX_AGE * 1e3 + window;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
761
1600
|
// src/sdk/key-manager.ts
|
|
762
1601
|
var import_node_fs = require("fs");
|
|
763
1602
|
var import_node_fs2 = require("fs");
|
|
@@ -828,17 +1667,54 @@ var KeyManager = class {
|
|
|
828
1667
|
(0, import_node_fs.writeFileSync)(tokenPath, token);
|
|
829
1668
|
this._setPermissions(tokenPath);
|
|
830
1669
|
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Store the address `relayUrl` gave this agent.
|
|
1672
|
+
*/
|
|
1673
|
+
saveAddress(name, address2, relayUrl) {
|
|
1674
|
+
(0, import_node_fs.mkdirSync)(this._keyDir, { recursive: true });
|
|
1675
|
+
(0, import_node_fs.writeFileSync)(
|
|
1676
|
+
(0, import_node_path2.join)(this._keyDir, `${name}.address`),
|
|
1677
|
+
JSON.stringify({ address: address2, relay_url: relayUrl })
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Load the address saved for `relayUrl`.
|
|
1682
|
+
*
|
|
1683
|
+
* Returns null if there is none: the file is missing, unreadable or
|
|
1684
|
+
* malformed, or it belongs to another relay.
|
|
1685
|
+
*/
|
|
1686
|
+
loadAddress(name, relayUrl) {
|
|
1687
|
+
try {
|
|
1688
|
+
const path = (0, import_node_path2.join)(this._keyDir, `${name}.address`);
|
|
1689
|
+
const saved = JSON.parse((0, import_node_fs.readFileSync)(path, "utf-8"));
|
|
1690
|
+
const address2 = saved?.address;
|
|
1691
|
+
const savedRelay = saved?.relay_url;
|
|
1692
|
+
if (typeof address2 !== "string" || typeof savedRelay !== "string") {
|
|
1693
|
+
return null;
|
|
1694
|
+
}
|
|
1695
|
+
if (trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl)) return null;
|
|
1696
|
+
parseAddress(address2);
|
|
1697
|
+
return address2;
|
|
1698
|
+
} catch {
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
831
1702
|
/**
|
|
832
1703
|
* Load a previously saved token, or return null.
|
|
833
1704
|
*
|
|
834
1705
|
* Checks UAM_TOKEN env var first, then file-based storage,
|
|
835
|
-
* then legacy .api_key files for backward compatibility.
|
|
1706
|
+
* then legacy .api_key files for backward compatibility. With `relayUrl`,
|
|
1707
|
+
* a saved token that belongs to another relay counts as none, so it is
|
|
1708
|
+
* only offered to the relay that issued it.
|
|
836
1709
|
*/
|
|
837
|
-
loadToken(name) {
|
|
1710
|
+
loadToken(name, relayUrl) {
|
|
838
1711
|
const envToken = process.env["UAM_TOKEN"];
|
|
839
1712
|
if (envToken) {
|
|
840
1713
|
return envToken.trim();
|
|
841
1714
|
}
|
|
1715
|
+
if (relayUrl !== void 0 && this._savedForAnotherRelay(name, relayUrl)) {
|
|
1716
|
+
return null;
|
|
1717
|
+
}
|
|
842
1718
|
const tokenPath = (0, import_node_path2.join)(this._keyDir, `${name}.token`);
|
|
843
1719
|
if ((0, import_node_fs2.existsSync)(tokenPath)) {
|
|
844
1720
|
return (0, import_node_fs.readFileSync)(tokenPath, "utf-8").trim();
|
|
@@ -849,6 +1725,20 @@ var KeyManager = class {
|
|
|
849
1725
|
}
|
|
850
1726
|
return null;
|
|
851
1727
|
}
|
|
1728
|
+
/**
|
|
1729
|
+
* True if the saved address says this agent's files are another relay's.
|
|
1730
|
+
* A missing or damaged file says nothing.
|
|
1731
|
+
*/
|
|
1732
|
+
_savedForAnotherRelay(name, relayUrl) {
|
|
1733
|
+
try {
|
|
1734
|
+
const path = (0, import_node_path2.join)(this._keyDir, `${name}.address`);
|
|
1735
|
+
const saved = JSON.parse((0, import_node_fs.readFileSync)(path, "utf-8"));
|
|
1736
|
+
const savedRelay = saved?.relay_url;
|
|
1737
|
+
return typeof savedRelay === "string" && trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl);
|
|
1738
|
+
} catch {
|
|
1739
|
+
return false;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
852
1742
|
/** Set file permissions to 0o600 (owner read/write only). */
|
|
853
1743
|
_setPermissions(path) {
|
|
854
1744
|
if ((0, import_node_os3.platform)() !== "win32") {
|
|
@@ -872,10 +1762,60 @@ var KeyManager = class {
|
|
|
872
1762
|
}
|
|
873
1763
|
};
|
|
874
1764
|
|
|
1765
|
+
// src/sdk/message.ts
|
|
1766
|
+
var SEND_STATUSES = [
|
|
1767
|
+
"delivered",
|
|
1768
|
+
"stored",
|
|
1769
|
+
"relayed",
|
|
1770
|
+
"queued",
|
|
1771
|
+
"duplicate",
|
|
1772
|
+
"dropped",
|
|
1773
|
+
"pending",
|
|
1774
|
+
"accepted",
|
|
1775
|
+
"unknown"
|
|
1776
|
+
];
|
|
1777
|
+
function sendStatus(answer) {
|
|
1778
|
+
if (answer === null) return "unknown";
|
|
1779
|
+
const status = answer["status"];
|
|
1780
|
+
if (typeof status === "string" && SEND_STATUSES.includes(status) && status !== "accepted" && status !== "unknown") {
|
|
1781
|
+
return status;
|
|
1782
|
+
}
|
|
1783
|
+
return "accepted";
|
|
1784
|
+
}
|
|
1785
|
+
function supports(features, name, version = 1) {
|
|
1786
|
+
const prefix = `${name}:`;
|
|
1787
|
+
for (const feature of features) {
|
|
1788
|
+
if (feature === name && version <= 1) return true;
|
|
1789
|
+
if (feature.startsWith(prefix)) {
|
|
1790
|
+
const rest = feature.slice(prefix.length);
|
|
1791
|
+
if (/^\d+$/.test(rest) && Number(rest) >= version) return true;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
return false;
|
|
1795
|
+
}
|
|
1796
|
+
var PENDING_NOTE = "Not delivered: {to} only accepts messages from approved contacts. Do not resend it.";
|
|
1797
|
+
function pendingNote(to) {
|
|
1798
|
+
return PENDING_NOTE.replace("{to}", to);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
875
1801
|
// src/sdk/contact-book.ts
|
|
876
1802
|
var import_node_fs3 = require("fs");
|
|
877
1803
|
var import_node_path3 = require("path");
|
|
878
1804
|
var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
|
|
1805
|
+
|
|
1806
|
+
// src/sdk/log.ts
|
|
1807
|
+
var log = {
|
|
1808
|
+
warn(message) {
|
|
1809
|
+
console.warn(`[uam] ${message}`);
|
|
1810
|
+
},
|
|
1811
|
+
info(message) {
|
|
1812
|
+
if (process.env["UAM_DEBUG"]) {
|
|
1813
|
+
console.info(`[uam] ${message}`);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
};
|
|
1817
|
+
|
|
1818
|
+
// src/sdk/contact-book.ts
|
|
879
1819
|
var ContactBook = class {
|
|
880
1820
|
_dbPath;
|
|
881
1821
|
_db = null;
|
|
@@ -955,36 +1895,36 @@ var ContactBook = class {
|
|
|
955
1895
|
/**
|
|
956
1896
|
* Check if an address is in the contact book (in-memory, no I/O).
|
|
957
1897
|
*/
|
|
958
|
-
isKnown(
|
|
959
|
-
return this._knownAddresses.has(
|
|
1898
|
+
isKnown(address2) {
|
|
1899
|
+
return this._knownAddresses.has(address2);
|
|
960
1900
|
}
|
|
961
1901
|
/**
|
|
962
1902
|
* Check if an address matches any blocked pattern (O(1) set lookup).
|
|
963
1903
|
*/
|
|
964
|
-
isBlocked(
|
|
965
|
-
if (this._blockedExact.has(
|
|
1904
|
+
isBlocked(address2) {
|
|
1905
|
+
if (this._blockedExact.has(address2)) {
|
|
966
1906
|
return true;
|
|
967
1907
|
}
|
|
968
|
-
if (
|
|
969
|
-
const
|
|
970
|
-
return this._blockedDomains.has(
|
|
1908
|
+
if (address2.includes("::")) {
|
|
1909
|
+
const domain2 = address2.split("::")[1];
|
|
1910
|
+
return this._blockedDomains.has(domain2);
|
|
971
1911
|
}
|
|
972
1912
|
return false;
|
|
973
1913
|
}
|
|
974
1914
|
/**
|
|
975
1915
|
* Look up the public key for a known contact.
|
|
976
1916
|
*/
|
|
977
|
-
getPublicKey(
|
|
1917
|
+
getPublicKey(address2) {
|
|
978
1918
|
if (this._db === null) return null;
|
|
979
|
-
const row = this._db.prepare("SELECT public_key FROM contacts WHERE address = ?").get(
|
|
1919
|
+
const row = this._db.prepare("SELECT public_key FROM contacts WHERE address = ?").get(address2);
|
|
980
1920
|
return row?.public_key ?? null;
|
|
981
1921
|
}
|
|
982
1922
|
/**
|
|
983
1923
|
* Return the relay URLs for a known contact (CARD-04).
|
|
984
1924
|
*/
|
|
985
|
-
getRelayUrls(
|
|
1925
|
+
getRelayUrls(address2) {
|
|
986
1926
|
if (this._db === null) return null;
|
|
987
|
-
const row = this._db.prepare("SELECT relay, relays_json FROM contacts WHERE address = ?").get(
|
|
1927
|
+
const row = this._db.prepare("SELECT relay, relays_json FROM contacts WHERE address = ?").get(address2);
|
|
988
1928
|
if (!row) return null;
|
|
989
1929
|
if (row.relays_json !== null) {
|
|
990
1930
|
return JSON.parse(row.relays_json);
|
|
@@ -996,11 +1936,30 @@ var ContactBook = class {
|
|
|
996
1936
|
}
|
|
997
1937
|
/**
|
|
998
1938
|
* Add or update a contact (upsert).
|
|
1939
|
+
*
|
|
1940
|
+
* Refuses to replace the key of a contact whose trust_state is "pinned"
|
|
1941
|
+
* or "verified" with a different key, unless `force: true` is passed (for
|
|
1942
|
+
* a deliberate key rotation, which is logged). Matches the Python SDK's
|
|
1943
|
+
* ContactBook.add_contact.
|
|
1944
|
+
*
|
|
1945
|
+
* @throws {KeyPinningError} If a pinned/verified contact's key would be
|
|
1946
|
+
* replaced by a different key without `force`.
|
|
999
1947
|
*/
|
|
1000
|
-
addContact(
|
|
1948
|
+
addContact(address2, publicKey, options) {
|
|
1001
1949
|
if (this._db === null) {
|
|
1002
1950
|
throw new Error("ContactBook not open. Call open() first.");
|
|
1003
1951
|
}
|
|
1952
|
+
const existing = this._db.prepare("SELECT public_key, trust_state FROM contacts WHERE address = ?").get(address2);
|
|
1953
|
+
if (existing !== void 0 && (existing.trust_state === "pinned" || existing.trust_state === "verified") && existing.public_key !== publicKey) {
|
|
1954
|
+
if (!options?.force) {
|
|
1955
|
+
throw new KeyPinningError(
|
|
1956
|
+
`Refusing to overwrite ${existing.trust_state} contact ${JSON.stringify(address2)}: stored public key does not match the new key. Pass force: true to rotate.`
|
|
1957
|
+
);
|
|
1958
|
+
}
|
|
1959
|
+
log.warn(
|
|
1960
|
+
`Force-replacing the key of ${existing.trust_state} contact ${address2} (key rotation)`
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1004
1963
|
const displayName = options?.displayName ?? null;
|
|
1005
1964
|
const trustState = options?.trustState ?? "trusted";
|
|
1006
1965
|
const trustSource = options?.trustSource ?? null;
|
|
@@ -1017,8 +1976,8 @@ var ContactBook = class {
|
|
|
1017
1976
|
relay = COALESCE(excluded.relay, contacts.relay),
|
|
1018
1977
|
relays_json = COALESCE(excluded.relays_json, contacts.relays_json),
|
|
1019
1978
|
last_seen = datetime('now')`
|
|
1020
|
-
).run(
|
|
1021
|
-
this._knownAddresses.add(
|
|
1979
|
+
).run(address2, publicKey, displayName, trustState, trustSource, relay, relaysJson);
|
|
1980
|
+
this._knownAddresses.add(address2);
|
|
1022
1981
|
}
|
|
1023
1982
|
/**
|
|
1024
1983
|
* Return all contacts sorted by last_seen DESC.
|
|
@@ -1039,13 +1998,13 @@ var ContactBook = class {
|
|
|
1039
1998
|
/**
|
|
1040
1999
|
* Store a pending handshake request.
|
|
1041
2000
|
*/
|
|
1042
|
-
addPending(
|
|
2001
|
+
addPending(address2, contactCardJson) {
|
|
1043
2002
|
if (this._db === null) {
|
|
1044
2003
|
throw new Error("ContactBook not open. Call open() first.");
|
|
1045
2004
|
}
|
|
1046
2005
|
this._db.prepare(
|
|
1047
2006
|
`INSERT OR REPLACE INTO pending_handshakes (address, contact_card) VALUES (?, ?)`
|
|
1048
|
-
).run(
|
|
2007
|
+
).run(address2, contactCardJson);
|
|
1049
2008
|
}
|
|
1050
2009
|
/**
|
|
1051
2010
|
* Retrieve all pending handshake requests.
|
|
@@ -1064,9 +2023,9 @@ var ContactBook = class {
|
|
|
1064
2023
|
/**
|
|
1065
2024
|
* Remove a pending handshake request.
|
|
1066
2025
|
*/
|
|
1067
|
-
removePending(
|
|
2026
|
+
removePending(address2) {
|
|
1068
2027
|
if (this._db === null) return;
|
|
1069
|
-
this._db.prepare("DELETE FROM pending_handshakes WHERE address = ?").run(
|
|
2028
|
+
this._db.prepare("DELETE FROM pending_handshakes WHERE address = ?").run(address2);
|
|
1070
2029
|
}
|
|
1071
2030
|
/**
|
|
1072
2031
|
* Return pending handshakes older than N days.
|
|
@@ -1086,31 +2045,31 @@ var ContactBook = class {
|
|
|
1086
2045
|
/**
|
|
1087
2046
|
* Return the trust_state for an address, or null if unknown.
|
|
1088
2047
|
*/
|
|
1089
|
-
getTrustState(
|
|
2048
|
+
getTrustState(address2) {
|
|
1090
2049
|
if (this._db === null) return null;
|
|
1091
|
-
const row = this._db.prepare("SELECT trust_state FROM contacts WHERE address = ?").get(
|
|
2050
|
+
const row = this._db.prepare("SELECT trust_state FROM contacts WHERE address = ?").get(address2);
|
|
1092
2051
|
return row?.trust_state ?? null;
|
|
1093
2052
|
}
|
|
1094
2053
|
/**
|
|
1095
2054
|
* Set the pinned_at timestamp and trust_state='pinned' for a contact (TOFU).
|
|
1096
2055
|
*/
|
|
1097
|
-
setPinnedAt(
|
|
2056
|
+
setPinnedAt(address2) {
|
|
1098
2057
|
if (this._db === null) {
|
|
1099
2058
|
throw new Error("ContactBook not open. Call open() first.");
|
|
1100
2059
|
}
|
|
1101
2060
|
this._db.prepare(
|
|
1102
2061
|
"UPDATE contacts SET pinned_at = datetime('now'), trust_state = 'pinned' WHERE address = ?"
|
|
1103
|
-
).run(
|
|
2062
|
+
).run(address2);
|
|
1104
2063
|
}
|
|
1105
2064
|
/**
|
|
1106
2065
|
* Remove a contact by address. Returns true if a contact was deleted.
|
|
1107
2066
|
*/
|
|
1108
|
-
removeContact(
|
|
2067
|
+
removeContact(address2) {
|
|
1109
2068
|
if (this._db === null) {
|
|
1110
2069
|
throw new Error("ContactBook not open. Call open() first.");
|
|
1111
2070
|
}
|
|
1112
|
-
const result = this._db.prepare("DELETE FROM contacts WHERE address = ?").run(
|
|
1113
|
-
this._knownAddresses.delete(
|
|
2071
|
+
const result = this._db.prepare("DELETE FROM contacts WHERE address = ?").run(address2);
|
|
2072
|
+
this._knownAddresses.delete(address2);
|
|
1114
2073
|
return result.changes > 0;
|
|
1115
2074
|
}
|
|
1116
2075
|
/**
|
|
@@ -1118,8 +2077,8 @@ var ContactBook = class {
|
|
|
1118
2077
|
*
|
|
1119
2078
|
* Convenience helper for inbound message filtering (CARD-05, TOFU-02).
|
|
1120
2079
|
*/
|
|
1121
|
-
isTrustedOrVerified(
|
|
1122
|
-
const state = this.getTrustState(
|
|
2080
|
+
isTrustedOrVerified(address2) {
|
|
2081
|
+
const state = this.getTrustState(address2);
|
|
1123
2082
|
return state === "trusted" || state === "verified" || state === "pinned";
|
|
1124
2083
|
}
|
|
1125
2084
|
/**
|
|
@@ -1173,6 +2132,18 @@ var ContactBook = class {
|
|
|
1173
2132
|
};
|
|
1174
2133
|
|
|
1175
2134
|
// src/sdk/handshake.ts
|
|
2135
|
+
function bindCardToEnvelope(card, envelope, senderVk) {
|
|
2136
|
+
if (card.address !== envelope.fromAddress) {
|
|
2137
|
+
throw new InvalidEnvelopeError(
|
|
2138
|
+
`Contact card address ${JSON.stringify(card.address)} does not match envelope sender ${JSON.stringify(envelope.fromAddress)}`
|
|
2139
|
+
);
|
|
2140
|
+
}
|
|
2141
|
+
if (card.publicKey !== serializeVerifyKey(senderVk)) {
|
|
2142
|
+
throw new InvalidEnvelopeError(
|
|
2143
|
+
"Contact card public_key does not match envelope sender's verify key"
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
1176
2147
|
var HandshakeManager = class {
|
|
1177
2148
|
_contactBook;
|
|
1178
2149
|
_trustPolicy;
|
|
@@ -1215,7 +2186,7 @@ var HandshakeManager = class {
|
|
|
1215
2186
|
if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */) {
|
|
1216
2187
|
await this._handleRequest(agent, envelope, senderVk);
|
|
1217
2188
|
} else if (envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */) {
|
|
1218
|
-
await this._handleAccept(envelope, senderVk);
|
|
2189
|
+
await this._handleAccept(agent, envelope, senderVk);
|
|
1219
2190
|
} else if (envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
|
|
1220
2191
|
}
|
|
1221
2192
|
return null;
|
|
@@ -1233,6 +2204,7 @@ var HandshakeManager = class {
|
|
|
1233
2204
|
);
|
|
1234
2205
|
const card = contactCardFromDict(cardDict);
|
|
1235
2206
|
verifyContactCard(card);
|
|
2207
|
+
bindCardToEnvelope(card, envelope, senderVk);
|
|
1236
2208
|
if (this._trustPolicy === "auto-accept") {
|
|
1237
2209
|
this._contactBook.addContact(card.address, card.publicKey, {
|
|
1238
2210
|
displayName: card.displayName,
|
|
@@ -1251,8 +2223,46 @@ var HandshakeManager = class {
|
|
|
1251
2223
|
}
|
|
1252
2224
|
/**
|
|
1253
2225
|
* Process a handshake.accept: store the sender as pinned (TOFU).
|
|
2226
|
+
*
|
|
2227
|
+
* Only honoured when:
|
|
2228
|
+
* 1. we previously sent this address a handshake.request
|
|
2229
|
+
* (trust_state === "handshake-sent"); anything else is an unsolicited
|
|
2230
|
+
* accept and is dropped. This also means an accept can never
|
|
2231
|
+
* downgrade a trusted/verified contact to pinned;
|
|
2232
|
+
* 2. the payload decrypts with our key and the sender's key, and parses
|
|
2233
|
+
* as JSON. Otherwise it is dropped without pinning;
|
|
2234
|
+
* 3. an embedded contact_card, if present, is well-formed, correctly
|
|
2235
|
+
* self-signed, and bound to the envelope sender (throws otherwise).
|
|
1254
2236
|
*/
|
|
1255
|
-
async _handleAccept(envelope, senderVk) {
|
|
2237
|
+
async _handleAccept(agent, envelope, senderVk) {
|
|
2238
|
+
const prior = this._contactBook.getTrustState(envelope.fromAddress);
|
|
2239
|
+
if (prior !== "handshake-sent") {
|
|
2240
|
+
log.warn(
|
|
2241
|
+
`Ignoring unsolicited handshake.accept from ${envelope.fromAddress} (trust_state=${JSON.stringify(prior)})`
|
|
2242
|
+
);
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
let payload;
|
|
2246
|
+
try {
|
|
2247
|
+
const plaintext = decryptPayload(
|
|
2248
|
+
envelope.payload,
|
|
2249
|
+
agent._keyManager.signingKey,
|
|
2250
|
+
senderVk
|
|
2251
|
+
);
|
|
2252
|
+
payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
2253
|
+
} catch {
|
|
2254
|
+
log.warn(
|
|
2255
|
+
`Dropping handshake.accept ${envelope.messageId} from ${envelope.fromAddress}: payload could not be decrypted or parsed`
|
|
2256
|
+
);
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
if (payload !== null && typeof payload === "object" && "contact_card" in payload) {
|
|
2260
|
+
const card = contactCardFromDict(
|
|
2261
|
+
payload["contact_card"]
|
|
2262
|
+
);
|
|
2263
|
+
verifyContactCard(card);
|
|
2264
|
+
bindCardToEnvelope(card, envelope, senderVk);
|
|
2265
|
+
}
|
|
1256
2266
|
const senderPkStr = serializeVerifyKey(senderVk);
|
|
1257
2267
|
this._contactBook.addContact(envelope.fromAddress, senderPkStr, {
|
|
1258
2268
|
trustState: "pinned"
|
|
@@ -1309,6 +2319,10 @@ var HandshakeManager = class {
|
|
|
1309
2319
|
}
|
|
1310
2320
|
};
|
|
1311
2321
|
|
|
2322
|
+
// src/sdk/resolver-base.ts
|
|
2323
|
+
var AddressResolver = class {
|
|
2324
|
+
};
|
|
2325
|
+
|
|
1312
2326
|
// src/sdk/tier3.ts
|
|
1313
2327
|
var import_viem = require("viem");
|
|
1314
2328
|
var DEFAULT_RPC_URL = "https://sepolia.base.org";
|
|
@@ -1361,8 +2375,8 @@ var Tier3Resolver = class extends AddressResolver {
|
|
|
1361
2375
|
transport: (0, import_viem.http)(rpcUrl)
|
|
1362
2376
|
});
|
|
1363
2377
|
}
|
|
1364
|
-
async resolvePublicKey(
|
|
1365
|
-
const parsed = parseAddress(
|
|
2378
|
+
async resolvePublicKey(address2, _token, _relayUrl) {
|
|
2379
|
+
const parsed = parseAddress(address2);
|
|
1366
2380
|
const name = parsed.domain;
|
|
1367
2381
|
const cached = this._cache.get(name);
|
|
1368
2382
|
if (cached && cached.expiresAt > Date.now()) {
|
|
@@ -1425,15 +2439,13 @@ var Tier3Resolver = class extends AddressResolver {
|
|
|
1425
2439
|
|
|
1426
2440
|
// src/sdk/resolver.ts
|
|
1427
2441
|
init_dns_verifier();
|
|
1428
|
-
var AddressResolver = class {
|
|
1429
|
-
};
|
|
1430
2442
|
var Tier1Resolver = class extends AddressResolver {
|
|
1431
|
-
async resolvePublicKey(
|
|
2443
|
+
async resolvePublicKey(address2, _token, relayUrl) {
|
|
1432
2444
|
const resp = await fetch(
|
|
1433
|
-
`${relayUrl}/api/v1/agents/${
|
|
2445
|
+
`${relayUrl}/api/v1/agents/${address2}/public-key`
|
|
1434
2446
|
);
|
|
1435
2447
|
if (resp.status === 404) {
|
|
1436
|
-
throw new UAMError(`Agent not found: ${
|
|
2448
|
+
throw new UAMError(`Agent not found: ${address2}`);
|
|
1437
2449
|
}
|
|
1438
2450
|
if (!resp.ok) {
|
|
1439
2451
|
throw new UAMError(
|
|
@@ -1445,8 +2457,8 @@ var Tier1Resolver = class extends AddressResolver {
|
|
|
1445
2457
|
}
|
|
1446
2458
|
};
|
|
1447
2459
|
var Tier2Resolver = class extends AddressResolver {
|
|
1448
|
-
async resolvePublicKey(
|
|
1449
|
-
const parsed = parseAddress(
|
|
2460
|
+
async resolvePublicKey(address2, _token, _relayUrl) {
|
|
2461
|
+
const parsed = parseAddress(address2);
|
|
1450
2462
|
const txtRecords = await queryUamTxt(parsed.domain);
|
|
1451
2463
|
for (const txt of txtRecords) {
|
|
1452
2464
|
const tags = parseUamTxt(txt);
|
|
@@ -1458,37 +2470,37 @@ var Tier2Resolver = class extends AddressResolver {
|
|
|
1458
2470
|
const httpsKey = await resolveKeyViaHttps(parsed.agent, parsed.domain);
|
|
1459
2471
|
if (httpsKey) return httpsKey;
|
|
1460
2472
|
throw new UAMError(
|
|
1461
|
-
`Could not resolve public key for ${
|
|
2473
|
+
`Could not resolve public key for ${address2} via DNS or HTTPS`
|
|
1462
2474
|
);
|
|
1463
2475
|
}
|
|
1464
2476
|
};
|
|
1465
2477
|
var SmartResolver = class extends AddressResolver {
|
|
1466
|
-
|
|
2478
|
+
relayDomain;
|
|
1467
2479
|
_tier1;
|
|
1468
2480
|
_tier2;
|
|
1469
2481
|
_tier3;
|
|
1470
2482
|
constructor(relayDomain, tier3Config) {
|
|
1471
2483
|
super();
|
|
1472
|
-
this.
|
|
2484
|
+
this.relayDomain = relayDomain;
|
|
1473
2485
|
this._tier1 = new Tier1Resolver();
|
|
1474
2486
|
this._tier2 = new Tier2Resolver();
|
|
1475
2487
|
this._tier3 = tier3Config ? new Tier3Resolver(tier3Config) : null;
|
|
1476
2488
|
}
|
|
1477
|
-
async resolvePublicKey(
|
|
1478
|
-
const parsed = parseAddress(
|
|
1479
|
-
const
|
|
1480
|
-
if (
|
|
1481
|
-
return this._tier1.resolvePublicKey(
|
|
2489
|
+
async resolvePublicKey(address2, token, relayUrl) {
|
|
2490
|
+
const parsed = parseAddress(address2);
|
|
2491
|
+
const domain2 = parsed.domain;
|
|
2492
|
+
if (domain2 === this.relayDomain) {
|
|
2493
|
+
return this._tier1.resolvePublicKey(address2, token, relayUrl);
|
|
1482
2494
|
}
|
|
1483
|
-
if (
|
|
1484
|
-
return this._tier2.resolvePublicKey(
|
|
2495
|
+
if (domain2.includes(".")) {
|
|
2496
|
+
return this._tier2.resolvePublicKey(address2, token, relayUrl);
|
|
1485
2497
|
}
|
|
1486
2498
|
if (!this._tier3) {
|
|
1487
2499
|
throw new UAMError(
|
|
1488
|
-
`Tier 3 resolution requires contract config. Cannot resolve dot-free domain: '${
|
|
2500
|
+
`Tier 3 resolution requires contract config. Cannot resolve dot-free domain: '${domain2}'`
|
|
1489
2501
|
);
|
|
1490
2502
|
}
|
|
1491
|
-
return this._tier3.resolvePublicKey(
|
|
2503
|
+
return this._tier3.resolvePublicKey(address2, token, relayUrl);
|
|
1492
2504
|
}
|
|
1493
2505
|
};
|
|
1494
2506
|
|
|
@@ -1497,15 +2509,23 @@ var TransportBase = class {
|
|
|
1497
2509
|
};
|
|
1498
2510
|
|
|
1499
2511
|
// src/sdk/transport/http.ts
|
|
2512
|
+
async function readAnswer(resp) {
|
|
2513
|
+
try {
|
|
2514
|
+
const body = await resp.json();
|
|
2515
|
+
return body !== null && typeof body === "object" && !Array.isArray(body) ? body : {};
|
|
2516
|
+
} catch {
|
|
2517
|
+
return {};
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
1500
2520
|
var HTTPTransport = class extends TransportBase {
|
|
1501
2521
|
_relayUrl;
|
|
1502
2522
|
_token;
|
|
1503
2523
|
_address;
|
|
1504
|
-
constructor(relayUrl, token,
|
|
2524
|
+
constructor(relayUrl, token, address2) {
|
|
1505
2525
|
super();
|
|
1506
2526
|
this._relayUrl = relayUrl.replace(/\/+$/, "");
|
|
1507
2527
|
this._token = token;
|
|
1508
|
-
this._address =
|
|
2528
|
+
this._address = address2;
|
|
1509
2529
|
}
|
|
1510
2530
|
async connect() {
|
|
1511
2531
|
}
|
|
@@ -1525,6 +2545,7 @@ var HTTPTransport = class extends TransportBase {
|
|
|
1525
2545
|
`HTTP send failed: ${resp.status} ${resp.statusText}`
|
|
1526
2546
|
);
|
|
1527
2547
|
}
|
|
2548
|
+
return readAnswer(resp);
|
|
1528
2549
|
}
|
|
1529
2550
|
async receive(limit = 50) {
|
|
1530
2551
|
const url = new URL(
|
|
@@ -1556,8 +2577,11 @@ var import_ws = __toESM(require("ws"), 1);
|
|
|
1556
2577
|
var BASE_DELAY = 1;
|
|
1557
2578
|
var MAX_DELAY = 60;
|
|
1558
2579
|
var JITTER_RANGE = 1;
|
|
2580
|
+
var SEND_SETTLE_SECONDS = 5;
|
|
2581
|
+
var ACK_TIMEOUT_SECONDS = 5;
|
|
1559
2582
|
var WebSocketTransport = class extends TransportBase {
|
|
1560
2583
|
_url;
|
|
2584
|
+
_token;
|
|
1561
2585
|
_onMessage;
|
|
1562
2586
|
_ws = null;
|
|
1563
2587
|
_pending = [];
|
|
@@ -1566,9 +2590,20 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1566
2590
|
_connectReject = null;
|
|
1567
2591
|
_running = false;
|
|
1568
2592
|
_reconnectTimeout = null;
|
|
2593
|
+
/** Envelopes sent that the relay hasn't answered yet. */
|
|
2594
|
+
_unanswered = 0;
|
|
2595
|
+
_settling = null;
|
|
2596
|
+
_settled = null;
|
|
2597
|
+
/** Sends waiting for the relay's answer, by message_id. */
|
|
2598
|
+
_waiting = /* @__PURE__ */ new Map();
|
|
2599
|
+
/** @internal How long a close waits, in ms. Replaceable in tests. */
|
|
2600
|
+
_settleMs = SEND_SETTLE_SECONDS * 1e3;
|
|
2601
|
+
/** @internal How long send() waits for an answer, in ms. Replaceable in tests. */
|
|
2602
|
+
_ackMs = ACK_TIMEOUT_SECONDS * 1e3;
|
|
1569
2603
|
constructor(wsUrl, token, onMessage) {
|
|
1570
2604
|
super();
|
|
1571
|
-
this._url =
|
|
2605
|
+
this._url = wsUrl;
|
|
2606
|
+
this._token = token;
|
|
1572
2607
|
this._onMessage = onMessage ?? null;
|
|
1573
2608
|
}
|
|
1574
2609
|
async connect() {
|
|
@@ -1589,7 +2624,14 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1589
2624
|
};
|
|
1590
2625
|
});
|
|
1591
2626
|
}
|
|
2627
|
+
/**
|
|
2628
|
+
* Close the connection, once the relay has answered what was sent.
|
|
2629
|
+
*
|
|
2630
|
+
* The relay stops reading a socket when it can't write an ack to it, so
|
|
2631
|
+
* closing straight after send() loses every frame but the first.
|
|
2632
|
+
*/
|
|
1592
2633
|
async disconnect() {
|
|
2634
|
+
await this._allAnswered();
|
|
1593
2635
|
this._running = false;
|
|
1594
2636
|
if (this._reconnectTimeout !== null) {
|
|
1595
2637
|
clearTimeout(this._reconnectTimeout);
|
|
@@ -1601,12 +2643,42 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1601
2643
|
this._ws = null;
|
|
1602
2644
|
}
|
|
1603
2645
|
this._connected = false;
|
|
2646
|
+
this._abandonWaiting();
|
|
1604
2647
|
}
|
|
2648
|
+
/**
|
|
2649
|
+
* Send an envelope and wait for the relay's answer.
|
|
2650
|
+
*
|
|
2651
|
+
* Resolves with the ack that names the message, or null when none came
|
|
2652
|
+
* within _ackMs or the socket went: the message may still have arrived.
|
|
2653
|
+
* Rejects with SendRefusedError when the relay refuses it by message_id
|
|
2654
|
+
* (relays before 1.1 don't name it, so their refusals end as null).
|
|
2655
|
+
*/
|
|
1605
2656
|
async send(envelope) {
|
|
1606
2657
|
if (!this._ws || this._ws.readyState !== import_ws.default.OPEN) {
|
|
1607
2658
|
throw new Error("WebSocket not connected");
|
|
1608
2659
|
}
|
|
2660
|
+
const messageId = envelope["message_id"];
|
|
2661
|
+
let answer = null;
|
|
2662
|
+
let timer;
|
|
2663
|
+
if (typeof messageId === "string" && !this._waiting.has(messageId)) {
|
|
2664
|
+
answer = new Promise((resolve, reject) => {
|
|
2665
|
+
this._waiting.set(messageId, { resolve, reject });
|
|
2666
|
+
timer = setTimeout(() => {
|
|
2667
|
+
log.warn(
|
|
2668
|
+
`No answer from the relay for ${messageId} within ${this._ackMs / 1e3}s`
|
|
2669
|
+
);
|
|
2670
|
+
this._settle(messageId, null);
|
|
2671
|
+
}, this._ackMs);
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
1609
2674
|
this._ws.send(JSON.stringify(envelope));
|
|
2675
|
+
this._unanswered += 1;
|
|
2676
|
+
if (answer === null) return null;
|
|
2677
|
+
try {
|
|
2678
|
+
return await answer;
|
|
2679
|
+
} finally {
|
|
2680
|
+
clearTimeout(timer);
|
|
2681
|
+
}
|
|
1610
2682
|
}
|
|
1611
2683
|
async receive(limit = 50) {
|
|
1612
2684
|
const result = this._pending.slice(0, limit);
|
|
@@ -1616,11 +2688,47 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1616
2688
|
async listen(callback) {
|
|
1617
2689
|
this._onMessage = callback;
|
|
1618
2690
|
}
|
|
2691
|
+
/** Resolves when nothing is unanswered, or after _settleMs regardless. */
|
|
2692
|
+
_allAnswered() {
|
|
2693
|
+
if (this._unanswered === 0) return Promise.resolve();
|
|
2694
|
+
this._settling ??= new Promise((resolve) => {
|
|
2695
|
+
const timeout = setTimeout(() => {
|
|
2696
|
+
log.warn(`Closing with ${this._unanswered} sends unanswered`);
|
|
2697
|
+
this._settled?.();
|
|
2698
|
+
}, this._settleMs);
|
|
2699
|
+
this._settled = () => {
|
|
2700
|
+
clearTimeout(timeout);
|
|
2701
|
+
this._settling = null;
|
|
2702
|
+
this._settled = null;
|
|
2703
|
+
resolve();
|
|
2704
|
+
};
|
|
2705
|
+
});
|
|
2706
|
+
return this._settling;
|
|
2707
|
+
}
|
|
2708
|
+
/** Hand the relay's answer (or a refusal) to the send waiting for it. */
|
|
2709
|
+
_settle(messageId, answer, error) {
|
|
2710
|
+
if (typeof messageId !== "string") return;
|
|
2711
|
+
const waiting = this._waiting.get(messageId);
|
|
2712
|
+
if (!waiting) return;
|
|
2713
|
+
this._waiting.delete(messageId);
|
|
2714
|
+
if (error) waiting.reject(error);
|
|
2715
|
+
else waiting.resolve(answer);
|
|
2716
|
+
}
|
|
2717
|
+
/** The socket went: no answers will come for what it carried. */
|
|
2718
|
+
_abandonWaiting() {
|
|
2719
|
+
const waiting = [...this._waiting.values()];
|
|
2720
|
+
this._waiting.clear();
|
|
2721
|
+
for (const w of waiting) w.resolve(null);
|
|
2722
|
+
}
|
|
2723
|
+
_answered() {
|
|
2724
|
+
this._unanswered = Math.max(0, this._unanswered - 1);
|
|
2725
|
+
if (this._unanswered === 0) this._settled?.();
|
|
2726
|
+
}
|
|
1619
2727
|
_startConnectionLoop() {
|
|
1620
2728
|
let attempt = 0;
|
|
1621
2729
|
const tryConnect = () => {
|
|
1622
2730
|
if (!this._running) return;
|
|
1623
|
-
const ws = new import_ws.default(this._url);
|
|
2731
|
+
const ws = new import_ws.default(this._url, [`bearer.${this._token}`]);
|
|
1624
2732
|
this._ws = ws;
|
|
1625
2733
|
ws.on("open", () => {
|
|
1626
2734
|
this._connected = true;
|
|
@@ -1641,6 +2749,9 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1641
2749
|
ws.on("close", () => {
|
|
1642
2750
|
this._connected = false;
|
|
1643
2751
|
this._ws = null;
|
|
2752
|
+
this._unanswered = 0;
|
|
2753
|
+
this._settled?.();
|
|
2754
|
+
this._abandonWaiting();
|
|
1644
2755
|
if (!this._running) return;
|
|
1645
2756
|
attempt += 1;
|
|
1646
2757
|
const delay = Math.min(BASE_DELAY * Math.pow(2, attempt), MAX_DELAY);
|
|
@@ -1657,33 +2768,279 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1657
2768
|
}
|
|
1658
2769
|
_handleMessage(msg) {
|
|
1659
2770
|
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) {
|
|
2771
|
+
if ("uam_version" in msg) {
|
|
1667
2772
|
if (this._onMessage) {
|
|
1668
2773
|
this._onMessage(msg).catch(() => {
|
|
1669
2774
|
});
|
|
1670
2775
|
} else {
|
|
1671
2776
|
this._pending.push(msg);
|
|
1672
2777
|
}
|
|
2778
|
+
} else if (msgType === "ping") {
|
|
2779
|
+
if (this._ws && this._ws.readyState === import_ws.default.OPEN) {
|
|
2780
|
+
this._ws.send(JSON.stringify({ type: "pong" }));
|
|
2781
|
+
}
|
|
2782
|
+
} else if (msgType === "ack") {
|
|
2783
|
+
this._answered();
|
|
2784
|
+
this._settle(msg["message_id"], msg);
|
|
2785
|
+
} else if (msgType === "relay.notice") {
|
|
2786
|
+
log.warn(
|
|
2787
|
+
`Relay notice: message ${String(msg["message_id"])} is ${String(msg["status"])}`
|
|
2788
|
+
);
|
|
2789
|
+
} else if (msgType === "error" || "error" in msg) {
|
|
2790
|
+
this._answered();
|
|
2791
|
+
const code = String(msg["error"] ?? msg["code"] ?? "error");
|
|
2792
|
+
const detail = typeof msg["detail"] === "string" ? msg["detail"] : void 0;
|
|
2793
|
+
log.warn(`Relay error: [${code}] ${detail ?? ""}`.trimEnd());
|
|
2794
|
+
const messageId = msg["message_id"];
|
|
2795
|
+
if (typeof messageId === "string") {
|
|
2796
|
+
this._settle(messageId, null, new SendRefusedError(messageId, code, detail));
|
|
2797
|
+
}
|
|
1673
2798
|
}
|
|
1674
2799
|
}
|
|
1675
2800
|
};
|
|
1676
2801
|
|
|
1677
2802
|
// src/sdk/transport/index.ts
|
|
1678
|
-
function createTransport(config, token,
|
|
2803
|
+
function createTransport(config, token, address2, onMessage) {
|
|
1679
2804
|
if (config.transportType === "http") {
|
|
1680
|
-
return new HTTPTransport(config.relayUrl, token,
|
|
2805
|
+
return new HTTPTransport(config.relayUrl, token, address2);
|
|
1681
2806
|
} else {
|
|
1682
2807
|
return new WebSocketTransport(config.relayWsUrl, token, onMessage);
|
|
1683
2808
|
}
|
|
1684
2809
|
}
|
|
1685
2810
|
|
|
2811
|
+
// src/sdk/replay-cache.ts
|
|
2812
|
+
var import_node_fs4 = require("fs");
|
|
2813
|
+
var import_node_path4 = require("path");
|
|
2814
|
+
var import_promises2 = require("timers/promises");
|
|
2815
|
+
var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
|
|
2816
|
+
var RETENTION_SECONDS = STORED_MAIL_MAX_AGE + 2 * MAX_ENVELOPE_AGE;
|
|
2817
|
+
var PURGE_INTERVAL_MS = 6e4;
|
|
2818
|
+
var WAL_ATTEMPTS = 100;
|
|
2819
|
+
var WAL_RETRY_MS = 100;
|
|
2820
|
+
var SCHEMA = `
|
|
2821
|
+
CREATE TABLE IF NOT EXISTS seen_envelopes (
|
|
2822
|
+
from_address TEXT NOT NULL,
|
|
2823
|
+
message_id TEXT NOT NULL,
|
|
2824
|
+
seen_at REAL NOT NULL,
|
|
2825
|
+
PRIMARY KEY (from_address, message_id)
|
|
2826
|
+
) WITHOUT ROWID;
|
|
2827
|
+
CREATE INDEX IF NOT EXISTS seen_envelopes_seen_at ON seen_envelopes (seen_at);
|
|
2828
|
+
CREATE TABLE IF NOT EXISTS replay_meta (
|
|
2829
|
+
key TEXT PRIMARY KEY,
|
|
2830
|
+
value TEXT NOT NULL
|
|
2831
|
+
);
|
|
2832
|
+
`;
|
|
2833
|
+
function isLocked(err) {
|
|
2834
|
+
const code = err.code;
|
|
2835
|
+
return typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"));
|
|
2836
|
+
}
|
|
2837
|
+
async function enableWal(db) {
|
|
2838
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
2839
|
+
try {
|
|
2840
|
+
db.pragma("journal_mode = WAL");
|
|
2841
|
+
return;
|
|
2842
|
+
} catch (err) {
|
|
2843
|
+
if (attempt >= WAL_ATTEMPTS || !isLocked(err)) throw err;
|
|
2844
|
+
await (0, import_promises2.setTimeout)(WAL_RETRY_MS);
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
var DEFAULT_REPLAY_CACHE_CAPACITY = 1e6;
|
|
2849
|
+
var EnvelopeReplayCache = class {
|
|
2850
|
+
capacity;
|
|
2851
|
+
ttlMs;
|
|
2852
|
+
_now;
|
|
2853
|
+
// Map iteration order is insertion order: first key = least recently used.
|
|
2854
|
+
_seen = /* @__PURE__ */ new Map();
|
|
2855
|
+
constructor(options) {
|
|
2856
|
+
const capacity = options?.capacity ?? 1e5;
|
|
2857
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
2858
|
+
throw new RangeError(
|
|
2859
|
+
`Replay cache capacity must be a positive integer, got ${capacity}`
|
|
2860
|
+
);
|
|
2861
|
+
}
|
|
2862
|
+
this.capacity = capacity;
|
|
2863
|
+
this.ttlMs = options?.ttlMs ?? 3e5;
|
|
2864
|
+
this._now = options?.now ?? (() => Date.now());
|
|
2865
|
+
}
|
|
2866
|
+
get size() {
|
|
2867
|
+
return this._seen.size;
|
|
2868
|
+
}
|
|
2869
|
+
/** True if this pair was seen within the TTL. Records nothing. */
|
|
2870
|
+
has(fromAddress, messageId) {
|
|
2871
|
+
const seenAt = this._seen.get(this._key(fromAddress, messageId));
|
|
2872
|
+
return seenAt !== void 0 && this._now() - seenAt < this.ttlMs;
|
|
2873
|
+
}
|
|
2874
|
+
/**
|
|
2875
|
+
* Return true if this (fromAddress, messageId) pair was already seen
|
|
2876
|
+
* within the TTL (a replay). Otherwise record it and return false.
|
|
2877
|
+
*/
|
|
2878
|
+
seenOrRecord(fromAddress, messageId) {
|
|
2879
|
+
const key = this._key(fromAddress, messageId);
|
|
2880
|
+
const now = this._now();
|
|
2881
|
+
const seenAt = this._seen.get(key);
|
|
2882
|
+
if (seenAt !== void 0 && now - seenAt < this.ttlMs) {
|
|
2883
|
+
this._seen.delete(key);
|
|
2884
|
+
this._seen.set(key, seenAt);
|
|
2885
|
+
return true;
|
|
2886
|
+
}
|
|
2887
|
+
this._seen.delete(key);
|
|
2888
|
+
this._seen.set(key, now);
|
|
2889
|
+
while (this._seen.size > this.capacity) {
|
|
2890
|
+
const oldest = this._seen.keys().next().value;
|
|
2891
|
+
this._seen.delete(oldest);
|
|
2892
|
+
}
|
|
2893
|
+
return false;
|
|
2894
|
+
}
|
|
2895
|
+
_key(fromAddress, messageId) {
|
|
2896
|
+
return JSON.stringify([fromAddress, messageId]);
|
|
2897
|
+
}
|
|
2898
|
+
};
|
|
2899
|
+
var PersistentReplayCache = class {
|
|
2900
|
+
capacity;
|
|
2901
|
+
_path;
|
|
2902
|
+
_now;
|
|
2903
|
+
_db = null;
|
|
2904
|
+
_opening = null;
|
|
2905
|
+
_count = 0;
|
|
2906
|
+
_lastPurge = 0;
|
|
2907
|
+
_createdAt = 0;
|
|
2908
|
+
// Used only while the file is full, for envelopes inside MAX_ENVELOPE_AGE.
|
|
2909
|
+
// One can be signed that far ahead of us and stay acceptable until that far
|
|
2910
|
+
// behind, so its id must last both.
|
|
2911
|
+
_overflow;
|
|
2912
|
+
/**
|
|
2913
|
+
* @param path - The SQLite file
|
|
2914
|
+
* @param options.now - The clock, in ms since the epoch
|
|
2915
|
+
*/
|
|
2916
|
+
constructor(path, options) {
|
|
2917
|
+
const capacity = options?.capacity ?? DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
2918
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
2919
|
+
throw new RangeError(
|
|
2920
|
+
`Replay cache capacity must be a positive integer, got ${capacity}`
|
|
2921
|
+
);
|
|
2922
|
+
}
|
|
2923
|
+
this.capacity = capacity;
|
|
2924
|
+
this._path = path;
|
|
2925
|
+
this._now = options?.now ?? (() => Date.now());
|
|
2926
|
+
this._overflow = new EnvelopeReplayCache({
|
|
2927
|
+
capacity: 1e4,
|
|
2928
|
+
ttlMs: 2 * MAX_ENVELOPE_AGE * 1e3,
|
|
2929
|
+
now: this._now
|
|
2930
|
+
});
|
|
2931
|
+
}
|
|
2932
|
+
/** Open the file now, rather than on first use. */
|
|
2933
|
+
async open() {
|
|
2934
|
+
await this._open();
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Return true if the envelope must be dropped as a replay. Otherwise record
|
|
2938
|
+
* the pair and return false.
|
|
2939
|
+
*
|
|
2940
|
+
* @param signedAt - The envelope's signed time, in ms since the epoch. It
|
|
2941
|
+
* matters only when the cache is full.
|
|
2942
|
+
*/
|
|
2943
|
+
async seenOrRecord(fromAddress, messageId, signedAt) {
|
|
2944
|
+
const db = await this._open();
|
|
2945
|
+
const now = this._now();
|
|
2946
|
+
if (now - this._lastPurge >= PURGE_INTERVAL_MS) this._purge(db);
|
|
2947
|
+
if (this._overflow.has(fromAddress, messageId)) return true;
|
|
2948
|
+
if (this._count < this.capacity) {
|
|
2949
|
+
const { changes } = db.prepare(
|
|
2950
|
+
"INSERT OR IGNORE INTO seen_envelopes (from_address, message_id, seen_at) VALUES (?, ?, ?)"
|
|
2951
|
+
).run(fromAddress, messageId, now / 1e3);
|
|
2952
|
+
if (changes === 0) return true;
|
|
2953
|
+
this._count += 1;
|
|
2954
|
+
return false;
|
|
2955
|
+
}
|
|
2956
|
+
const known = db.prepare(
|
|
2957
|
+
"SELECT 1 FROM seen_envelopes WHERE from_address = ? AND message_id = ?"
|
|
2958
|
+
).get(fromAddress, messageId);
|
|
2959
|
+
if (known !== void 0) return true;
|
|
2960
|
+
if (Math.abs(now - signedAt) <= MAX_ENVELOPE_AGE * 1e3) {
|
|
2961
|
+
return this._overflow.seenOrRecord(fromAddress, messageId);
|
|
2962
|
+
}
|
|
2963
|
+
log.warn(
|
|
2964
|
+
`Replay cache full (${this._count} ids); refused envelope ${messageId} from ${fromAddress} signed ${Math.round((now - signedAt) / 1e3)}s ago`
|
|
2965
|
+
);
|
|
2966
|
+
return true;
|
|
2967
|
+
}
|
|
2968
|
+
/** When this cache file was first created, in ms since the epoch. */
|
|
2969
|
+
async createdAt() {
|
|
2970
|
+
await this._open();
|
|
2971
|
+
return this._createdAt;
|
|
2972
|
+
}
|
|
2973
|
+
/** True if an envelope signed at `signedAt` (ms) predates this cache. */
|
|
2974
|
+
async signedBeforeCache(signedAt) {
|
|
2975
|
+
return signedAt < await this.createdAt();
|
|
2976
|
+
}
|
|
2977
|
+
/** Number of ids currently held on disk. */
|
|
2978
|
+
async count() {
|
|
2979
|
+
const db = await this._open();
|
|
2980
|
+
return this._countRows(db);
|
|
2981
|
+
}
|
|
2982
|
+
/** Close the file; the next call opens it again. */
|
|
2983
|
+
async close() {
|
|
2984
|
+
await this._opening?.catch(() => void 0);
|
|
2985
|
+
this._db?.close();
|
|
2986
|
+
this._db = null;
|
|
2987
|
+
}
|
|
2988
|
+
_open() {
|
|
2989
|
+
if (this._db !== null) return Promise.resolve(this._db);
|
|
2990
|
+
this._opening ??= this._connect().finally(() => {
|
|
2991
|
+
this._opening = null;
|
|
2992
|
+
});
|
|
2993
|
+
return this._opening;
|
|
2994
|
+
}
|
|
2995
|
+
async _connect() {
|
|
2996
|
+
(0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(this._path), { recursive: true });
|
|
2997
|
+
const db = new import_better_sqlite32.default(this._path, { timeout: 3e4 });
|
|
2998
|
+
try {
|
|
2999
|
+
await enableWal(db);
|
|
3000
|
+
db.exec(SCHEMA);
|
|
3001
|
+
db.prepare(
|
|
3002
|
+
"INSERT OR IGNORE INTO replay_meta (key, value) VALUES ('created_at', ?)"
|
|
3003
|
+
).run(String(this._now() / 1e3));
|
|
3004
|
+
const row = db.prepare("SELECT value FROM replay_meta WHERE key = 'created_at'").get();
|
|
3005
|
+
this._createdAt = Number(row.value) * 1e3;
|
|
3006
|
+
this._purge(db);
|
|
3007
|
+
} catch (err) {
|
|
3008
|
+
db.close();
|
|
3009
|
+
throw err;
|
|
3010
|
+
}
|
|
3011
|
+
this._db = db;
|
|
3012
|
+
return db;
|
|
3013
|
+
}
|
|
3014
|
+
_purge(db) {
|
|
3015
|
+
const now = this._now();
|
|
3016
|
+
db.prepare("DELETE FROM seen_envelopes WHERE seen_at < ?").run(
|
|
3017
|
+
now / 1e3 - RETENTION_SECONDS
|
|
3018
|
+
);
|
|
3019
|
+
this._count = this._countRows(db);
|
|
3020
|
+
this._lastPurge = now;
|
|
3021
|
+
}
|
|
3022
|
+
_countRows(db) {
|
|
3023
|
+
const row = db.prepare("SELECT COUNT(*) AS n FROM seen_envelopes").get();
|
|
3024
|
+
return row.n;
|
|
3025
|
+
}
|
|
3026
|
+
};
|
|
3027
|
+
|
|
1686
3028
|
// src/sdk/agent.ts
|
|
3029
|
+
var RELAY_NO_MEMORY_MS = 6e4;
|
|
3030
|
+
function replayCacheCapacityFromEnv() {
|
|
3031
|
+
const raw = process.env["UAM_REPLAY_CACHE_CAPACITY"];
|
|
3032
|
+
if (raw === void 0 || raw === "") {
|
|
3033
|
+
return DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
3034
|
+
}
|
|
3035
|
+
const n = Number(raw);
|
|
3036
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
3037
|
+
log.warn(
|
|
3038
|
+
`Ignoring invalid UAM_REPLAY_CACHE_CAPACITY=${JSON.stringify(raw)}; using ${DEFAULT_REPLAY_CACHE_CAPACITY}`
|
|
3039
|
+
);
|
|
3040
|
+
return DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
3041
|
+
}
|
|
3042
|
+
return n;
|
|
3043
|
+
}
|
|
1687
3044
|
var Agent = class {
|
|
1688
3045
|
/** @internal */
|
|
1689
3046
|
_config;
|
|
@@ -1700,6 +3057,23 @@ var Agent = class {
|
|
|
1700
3057
|
_contactBook;
|
|
1701
3058
|
/** @internal */
|
|
1702
3059
|
_handshake;
|
|
3060
|
+
/**
|
|
3061
|
+
* @internal Wall clock in ms since the epoch, used by the inbound
|
|
3062
|
+
* freshness/expiry checks and the replay cache. Replaceable in tests.
|
|
3063
|
+
*/
|
|
3064
|
+
_clock = () => Date.now();
|
|
3065
|
+
/**
|
|
3066
|
+
* @internal Inbound replay cache, keyed by (from address, message id).
|
|
3067
|
+
* Kept on disk under the data dir, so it outlives restarts and is shared by
|
|
3068
|
+
* every process using it; capacity from UAM_REPLAY_CACHE_CAPACITY.
|
|
3069
|
+
*/
|
|
3070
|
+
_replayCache;
|
|
3071
|
+
/** Whether our relay has said it writes relay_meta. */
|
|
3072
|
+
_relayStamps = false;
|
|
3073
|
+
/** Until when, by _monotonic, to take the relay's last no as still true. */
|
|
3074
|
+
_relayNoUntil = 0;
|
|
3075
|
+
/** @internal Monotonic clock in ms. Replaceable in tests. */
|
|
3076
|
+
_monotonic = () => performance.now();
|
|
1703
3077
|
/**
|
|
1704
3078
|
* Create an Agent. No I/O happens here -- call connect() to initialize.
|
|
1705
3079
|
*/
|
|
@@ -1721,6 +3095,13 @@ var Agent = class {
|
|
|
1721
3095
|
this._contactBook,
|
|
1722
3096
|
this._config.trustPolicy
|
|
1723
3097
|
);
|
|
3098
|
+
this._replayCache = new PersistentReplayCache(
|
|
3099
|
+
(0, import_node_path5.join)(this._config.dataDir, "replay", "replay.db"),
|
|
3100
|
+
{
|
|
3101
|
+
capacity: replayCacheCapacityFromEnv(),
|
|
3102
|
+
now: () => this._clock()
|
|
3103
|
+
}
|
|
3104
|
+
);
|
|
1724
3105
|
}
|
|
1725
3106
|
// -- Properties ----------------------------------------------------------
|
|
1726
3107
|
/** The agent's full UAM address (e.g., 'myagent::youam.network'). */
|
|
@@ -1734,7 +3115,7 @@ var Agent = class {
|
|
|
1734
3115
|
}
|
|
1735
3116
|
/** UAM SDK version. */
|
|
1736
3117
|
get version() {
|
|
1737
|
-
return
|
|
3118
|
+
return SDK_VERSION;
|
|
1738
3119
|
}
|
|
1739
3120
|
/** The agent's public key (base64-encoded Ed25519 verify key). */
|
|
1740
3121
|
get publicKey() {
|
|
@@ -1771,10 +3152,19 @@ var Agent = class {
|
|
|
1771
3152
|
}
|
|
1772
3153
|
await sodiumReady;
|
|
1773
3154
|
this._keyManager.loadOrGenerate(this._config.name);
|
|
1774
|
-
|
|
3155
|
+
try {
|
|
3156
|
+
await this._replayCache.open();
|
|
3157
|
+
} catch (err) {
|
|
3158
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3159
|
+
log.warn(`Could not open the replay cache: ${reason}`);
|
|
3160
|
+
}
|
|
3161
|
+
const storedToken = this._keyManager.loadToken(
|
|
3162
|
+
this._config.name,
|
|
3163
|
+
this._config.relayUrl
|
|
3164
|
+
);
|
|
1775
3165
|
if (storedToken) {
|
|
1776
3166
|
this._token = storedToken;
|
|
1777
|
-
this.
|
|
3167
|
+
this._useAddress(await this._storedAddress());
|
|
1778
3168
|
} else if (this._autoRegister) {
|
|
1779
3169
|
await this._registerWithRelay();
|
|
1780
3170
|
} else {
|
|
@@ -1797,6 +3187,7 @@ var Agent = class {
|
|
|
1797
3187
|
*/
|
|
1798
3188
|
async close() {
|
|
1799
3189
|
this._contactBook.close();
|
|
3190
|
+
await this._replayCache.close();
|
|
1800
3191
|
if (this._transport) {
|
|
1801
3192
|
await this._transport.disconnect();
|
|
1802
3193
|
}
|
|
@@ -1804,7 +3195,12 @@ var Agent = class {
|
|
|
1804
3195
|
}
|
|
1805
3196
|
// -- Messaging -----------------------------------------------------------
|
|
1806
3197
|
/**
|
|
1807
|
-
* Send an encrypted, signed message.
|
|
3198
|
+
* Send an encrypted, signed message.
|
|
3199
|
+
*
|
|
3200
|
+
* Returns the message id with what the relay did with it: `status`
|
|
3201
|
+
* "pending" means the recipient only takes approved contacts, and the
|
|
3202
|
+
* message was not kept (don't resend it). `delivered` is true only when
|
|
3203
|
+
* the relay says the recipient has it.
|
|
1808
3204
|
*/
|
|
1809
3205
|
async send(toAddress, message, options) {
|
|
1810
3206
|
await this._ensureConnected();
|
|
@@ -1817,7 +3213,8 @@ var Agent = class {
|
|
|
1817
3213
|
}
|
|
1818
3214
|
}
|
|
1819
3215
|
const recipientVk = await this._resolvePublicKey(toAddress);
|
|
1820
|
-
|
|
3216
|
+
const trustState = this._contactBook.getTrustState(toAddress);
|
|
3217
|
+
if (trustState !== "pinned" && trustState !== "verified" && trustState !== "trusted" && trustState !== "handshake-sent") {
|
|
1821
3218
|
await this._initiateHandshake(toAddress, recipientVk);
|
|
1822
3219
|
}
|
|
1823
3220
|
const envelope = await createEnvelope(
|
|
@@ -1835,20 +3232,29 @@ var Agent = class {
|
|
|
1835
3232
|
);
|
|
1836
3233
|
const wire = toWireDict(envelope);
|
|
1837
3234
|
const relayUrls = await this._getRelayUrls(toAddress);
|
|
3235
|
+
let answer;
|
|
1838
3236
|
if (relayUrls !== null && relayUrls.length === 1 && relayUrls[0] === this._config.relayUrl) {
|
|
1839
|
-
await this._transport.send(wire);
|
|
3237
|
+
answer = await this._transport.send(wire);
|
|
1840
3238
|
} else if (relayUrls !== null && relayUrls.length > 0) {
|
|
1841
|
-
await this._trySendWithFailover(wire, relayUrls);
|
|
3239
|
+
answer = await this._trySendWithFailover(wire, relayUrls);
|
|
1842
3240
|
} else {
|
|
1843
|
-
await this._transport.send(wire);
|
|
3241
|
+
answer = await this._transport.send(wire);
|
|
1844
3242
|
}
|
|
1845
|
-
|
|
3243
|
+
const status = sendStatus(answer);
|
|
3244
|
+
return {
|
|
3245
|
+
messageId: envelope.messageId,
|
|
3246
|
+
status,
|
|
3247
|
+
delivered: status === "delivered"
|
|
3248
|
+
};
|
|
1846
3249
|
}
|
|
1847
3250
|
/**
|
|
1848
3251
|
* Retrieve, decrypt, and verify pending messages.
|
|
1849
3252
|
*
|
|
1850
3253
|
* Returns a list of ReceivedMessage data objects.
|
|
1851
|
-
*
|
|
3254
|
+
* Drops messages with invalid signatures or decryption errors. A message
|
|
3255
|
+
* that fails processing for any other reason (malformed envelope, bad
|
|
3256
|
+
* handshake payload, ...) is logged and dropped; it never aborts the
|
|
3257
|
+
* rest of the batch.
|
|
1852
3258
|
*/
|
|
1853
3259
|
async inbox(limit = 50) {
|
|
1854
3260
|
await this._ensureConnected();
|
|
@@ -1856,7 +3262,15 @@ var Agent = class {
|
|
|
1856
3262
|
const rawMessages = await this._transport.receive(limit);
|
|
1857
3263
|
const result = [];
|
|
1858
3264
|
for (const raw of rawMessages) {
|
|
1859
|
-
|
|
3265
|
+
let msg;
|
|
3266
|
+
try {
|
|
3267
|
+
msg = await this._processInbound(raw);
|
|
3268
|
+
} catch (err) {
|
|
3269
|
+
log.warn(
|
|
3270
|
+
`Dropping inbound message ${String(raw["message_id"])} from ${String(raw["from"])}: ${err instanceof Error ? err.message : String(err)}`
|
|
3271
|
+
);
|
|
3272
|
+
continue;
|
|
3273
|
+
}
|
|
1860
3274
|
if (msg !== null) {
|
|
1861
3275
|
result.push(msg);
|
|
1862
3276
|
await this._sendReadReceipt(msg);
|
|
@@ -1876,42 +3290,43 @@ var Agent = class {
|
|
|
1876
3290
|
* Adds the sender to contacts with trust_source='explicit-approval'
|
|
1877
3291
|
* and sends handshake.accept back.
|
|
1878
3292
|
*/
|
|
1879
|
-
async approve(
|
|
3293
|
+
async approve(address2) {
|
|
1880
3294
|
await this._ensureConnected();
|
|
1881
3295
|
const pendingList = this._contactBook.getPending();
|
|
1882
|
-
const entry = pendingList.find((p) => p.address ===
|
|
3296
|
+
const entry = pendingList.find((p) => p.address === address2);
|
|
1883
3297
|
if (!entry) {
|
|
1884
|
-
throw new UAMError(`No pending handshake from ${
|
|
3298
|
+
throw new UAMError(`No pending handshake from ${address2}`);
|
|
1885
3299
|
}
|
|
1886
3300
|
const cardDict = JSON.parse(entry.contactCard);
|
|
1887
3301
|
const card = contactCardFromDict(cardDict);
|
|
1888
3302
|
verifyContactCard(card);
|
|
3303
|
+
const senderVk = deserializeVerifyKey(card.publicKey);
|
|
3304
|
+
bindCardToEnvelope(card, { fromAddress: address2 }, senderVk);
|
|
1889
3305
|
this._contactBook.addContact(card.address, card.publicKey, {
|
|
1890
3306
|
displayName: card.displayName,
|
|
1891
3307
|
trustState: "trusted",
|
|
1892
3308
|
trustSource: "explicit-approval"
|
|
1893
3309
|
});
|
|
1894
|
-
this._contactBook.removePending(
|
|
1895
|
-
|
|
1896
|
-
await this._handshake._sendAccept(this, address, senderVk);
|
|
3310
|
+
this._contactBook.removePending(address2);
|
|
3311
|
+
await this._handshake._sendAccept(this, address2, senderVk);
|
|
1897
3312
|
}
|
|
1898
3313
|
/**
|
|
1899
3314
|
* Deny a pending handshake request.
|
|
1900
3315
|
*
|
|
1901
3316
|
* Removes the entry from pending and sends handshake.deny.
|
|
1902
3317
|
*/
|
|
1903
|
-
async deny(
|
|
3318
|
+
async deny(address2) {
|
|
1904
3319
|
await this._ensureConnected();
|
|
1905
3320
|
const pendingList = this._contactBook.getPending();
|
|
1906
|
-
const entry = pendingList.find((p) => p.address ===
|
|
3321
|
+
const entry = pendingList.find((p) => p.address === address2);
|
|
1907
3322
|
if (!entry) {
|
|
1908
|
-
throw new UAMError(`No pending handshake from ${
|
|
3323
|
+
throw new UAMError(`No pending handshake from ${address2}`);
|
|
1909
3324
|
}
|
|
1910
3325
|
const cardDict = JSON.parse(entry.contactCard);
|
|
1911
3326
|
const card = contactCardFromDict(cardDict);
|
|
1912
3327
|
const senderVk = deserializeVerifyKey(card.publicKey);
|
|
1913
|
-
this._contactBook.removePending(
|
|
1914
|
-
await this._handshake._sendDeny(this,
|
|
3328
|
+
this._contactBook.removePending(address2);
|
|
3329
|
+
await this._handshake._sendDeny(this, address2, senderVk);
|
|
1915
3330
|
}
|
|
1916
3331
|
/**
|
|
1917
3332
|
* Block an address or domain pattern (HAND-04).
|
|
@@ -1937,7 +3352,7 @@ var Agent = class {
|
|
|
1937
3352
|
*
|
|
1938
3353
|
* Returns `true` if verified, `false` on timeout.
|
|
1939
3354
|
*/
|
|
1940
|
-
async verifyDomain(
|
|
3355
|
+
async verifyDomain(domain2, options) {
|
|
1941
3356
|
const { generateTxtRecord: generateTxtRecord2 } = await Promise.resolve().then(() => (init_dns_verifier(), dns_verifier_exports));
|
|
1942
3357
|
await this._ensureConnected();
|
|
1943
3358
|
const timeout = options?.timeout ?? 3e5;
|
|
@@ -1958,7 +3373,7 @@ var Agent = class {
|
|
|
1958
3373
|
"Content-Type": "application/json",
|
|
1959
3374
|
Authorization: `Bearer ${this._token}`
|
|
1960
3375
|
},
|
|
1961
|
-
body: JSON.stringify({ domain }),
|
|
3376
|
+
body: JSON.stringify({ domain: domain2 }),
|
|
1962
3377
|
signal: AbortSignal.timeout(3e4)
|
|
1963
3378
|
}
|
|
1964
3379
|
);
|
|
@@ -2011,7 +3426,7 @@ var Agent = class {
|
|
|
2011
3426
|
if (!resp.ok) {
|
|
2012
3427
|
throw new Error(`HTTP ${resp.status}`);
|
|
2013
3428
|
}
|
|
2014
|
-
return;
|
|
3429
|
+
return await readAnswer(resp);
|
|
2015
3430
|
} catch (err) {
|
|
2016
3431
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
2017
3432
|
}
|
|
@@ -2029,6 +3444,10 @@ var Agent = class {
|
|
|
2029
3444
|
/**
|
|
2030
3445
|
* Resolve a recipient's public key, checking contact book first.
|
|
2031
3446
|
*
|
|
3447
|
+
* Pure lookup: never writes to the contact book. The contact row is
|
|
3448
|
+
* created by _initiateHandshake (as "handshake-sent") once the request
|
|
3449
|
+
* has gone out, giving the lifecycle no row -> handshake-sent -> pinned.
|
|
3450
|
+
*
|
|
2032
3451
|
* TOFU-01: Known contacts always use stored key (zero network I/O).
|
|
2033
3452
|
* TOFU-03: Race-condition double-check after network resolve.
|
|
2034
3453
|
* Raises KeyPinningError if a pinned contact's key doesn't match.
|
|
@@ -2052,9 +3471,6 @@ var Agent = class {
|
|
|
2052
3471
|
);
|
|
2053
3472
|
}
|
|
2054
3473
|
}
|
|
2055
|
-
this._contactBook.addContact(toAddress, resolvedPkStr, {
|
|
2056
|
-
trustState: "provisional"
|
|
2057
|
-
});
|
|
2058
3474
|
return deserializeVerifyKey(resolvedPkStr);
|
|
2059
3475
|
}
|
|
2060
3476
|
async _initiateHandshake(toAddress, recipientVk) {
|
|
@@ -2101,6 +3517,9 @@ var Agent = class {
|
|
|
2101
3517
|
}
|
|
2102
3518
|
throw err;
|
|
2103
3519
|
}
|
|
3520
|
+
if (!await this._passesInboundChecks(envelope, raw)) {
|
|
3521
|
+
return null;
|
|
3522
|
+
}
|
|
2104
3523
|
if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */ || envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */ || envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
|
|
2105
3524
|
return this._handshake.handleInbound(this, envelope, senderVk);
|
|
2106
3525
|
}
|
|
@@ -2135,6 +3554,102 @@ var Agent = class {
|
|
|
2135
3554
|
verified: true
|
|
2136
3555
|
});
|
|
2137
3556
|
}
|
|
3557
|
+
/**
|
|
3558
|
+
* Recipient, freshness, expiry and replay checks for a signature-verified
|
|
3559
|
+
* inbound envelope. Returns false (and logs) when it must be dropped.
|
|
3560
|
+
* Matches the Python SDK's _process_inbound checks.
|
|
3561
|
+
*/
|
|
3562
|
+
async _passesInboundChecks(envelope, raw) {
|
|
3563
|
+
const id = envelope.messageId;
|
|
3564
|
+
const from = envelope.fromAddress;
|
|
3565
|
+
if (this._address !== null && envelope.toAddress !== this._address) {
|
|
3566
|
+
log.warn(
|
|
3567
|
+
`Dropped envelope ${id} from ${from} addressed to ${envelope.toAddress} (we are ${this._address})`
|
|
3568
|
+
);
|
|
3569
|
+
return false;
|
|
3570
|
+
}
|
|
3571
|
+
const now = this._clock();
|
|
3572
|
+
const ts = Date.parse(envelope.timestamp);
|
|
3573
|
+
if (Number.isNaN(ts)) {
|
|
3574
|
+
log.warn(
|
|
3575
|
+
`Dropped envelope ${id} from ${from} with malformed timestamp ` + JSON.stringify(envelope.timestamp)
|
|
3576
|
+
);
|
|
3577
|
+
return false;
|
|
3578
|
+
}
|
|
3579
|
+
const ageSeconds = Math.abs(now - ts) / 1e3;
|
|
3580
|
+
if (ageSeconds > MAX_ENVELOPE_AGE && !await this._wasStored(raw, envelope.toAddress, ts, now)) {
|
|
3581
|
+
log.info(
|
|
3582
|
+
`Dropped stale envelope ${id} from ${from} (age=${Math.round(ageSeconds)}s > ${MAX_ENVELOPE_AGE}s)`
|
|
3583
|
+
);
|
|
3584
|
+
return false;
|
|
3585
|
+
}
|
|
3586
|
+
if (envelope.expires !== void 0 && envelope.expires !== null) {
|
|
3587
|
+
const exp = Date.parse(envelope.expires);
|
|
3588
|
+
if (Number.isNaN(exp)) {
|
|
3589
|
+
log.warn(
|
|
3590
|
+
`Dropped envelope ${id} from ${from} with malformed expires ` + JSON.stringify(envelope.expires)
|
|
3591
|
+
);
|
|
3592
|
+
return false;
|
|
3593
|
+
}
|
|
3594
|
+
if (now > exp) {
|
|
3595
|
+
log.info(
|
|
3596
|
+
`Dropped expired envelope ${id} from ${from} (expires=${envelope.expires})`
|
|
3597
|
+
);
|
|
3598
|
+
return false;
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
if (await this._replayCache.seenOrRecord(from, id, ts)) {
|
|
3602
|
+
log.info(`Dropped replayed envelope ${id} from ${from}`);
|
|
3603
|
+
return false;
|
|
3604
|
+
}
|
|
3605
|
+
return true;
|
|
3606
|
+
}
|
|
3607
|
+
/** True if a message too old to be live is stored mail, not a replay. */
|
|
3608
|
+
async _wasStored(raw, toAddress, signedAt, now) {
|
|
3609
|
+
const relayDomain = toAddress.split("::").pop();
|
|
3610
|
+
if (!relayVouches(raw, signedAt, now, relayDomain)) return false;
|
|
3611
|
+
if (await this._replayCache.signedBeforeCache(signedAt)) return false;
|
|
3612
|
+
return this._relayStampsMail();
|
|
3613
|
+
}
|
|
3614
|
+
/**
|
|
3615
|
+
* True if our relay writes relay_meta, so a sender can't have.
|
|
3616
|
+
*
|
|
3617
|
+
* Asked only when a stale message carries a stamp. A yes is remembered for
|
|
3618
|
+
* good, a no for RELAY_NO_MEMORY_MS: a relay can be upgraded while this
|
|
3619
|
+
* agent runs. A failed lookup isn't remembered, because the relay has
|
|
3620
|
+
* already marked this mail delivered and the next message should get a
|
|
3621
|
+
* real answer.
|
|
3622
|
+
*/
|
|
3623
|
+
async _relayStampsMail() {
|
|
3624
|
+
if (this._relayStamps) return true;
|
|
3625
|
+
if (this._monotonic() < this._relayNoUntil) return false;
|
|
3626
|
+
try {
|
|
3627
|
+
const features = await this._fetchRelayFeatures();
|
|
3628
|
+
this._relayStamps = features.includes(RELAY_META_FEATURE);
|
|
3629
|
+
if (!this._relayStamps) {
|
|
3630
|
+
this._relayNoUntil = this._monotonic() + RELAY_NO_MEMORY_MS;
|
|
3631
|
+
}
|
|
3632
|
+
} catch (err) {
|
|
3633
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3634
|
+
log.warn(`Cannot read relay features: ${reason}`);
|
|
3635
|
+
}
|
|
3636
|
+
return this._relayStamps;
|
|
3637
|
+
}
|
|
3638
|
+
/** The relay's features; a body that doesn't list any is an empty list. */
|
|
3639
|
+
async _fetchRelayFeatures() {
|
|
3640
|
+
const resp = await fetch(`${this._config.relayUrl}/api/v1/versions`, {
|
|
3641
|
+
signal: AbortSignal.timeout(1e4)
|
|
3642
|
+
});
|
|
3643
|
+
if (!resp.ok) throw new UAMError(`Relay answered ${resp.status}`);
|
|
3644
|
+
const body = await resp.text();
|
|
3645
|
+
let data;
|
|
3646
|
+
try {
|
|
3647
|
+
data = JSON.parse(body);
|
|
3648
|
+
} catch {
|
|
3649
|
+
return [];
|
|
3650
|
+
}
|
|
3651
|
+
return Array.isArray(data?.features) ? data.features : [];
|
|
3652
|
+
}
|
|
2138
3653
|
/**
|
|
2139
3654
|
* Sweep expired pending handshakes and send receipt.failed (HAND-03).
|
|
2140
3655
|
*/
|
|
@@ -2187,7 +3702,8 @@ var Agent = class {
|
|
|
2187
3702
|
"receipt.read" /* RECEIPT_READ */,
|
|
2188
3703
|
new TextEncoder().encode(receiptPayload),
|
|
2189
3704
|
this._keyManager.signingKey,
|
|
2190
|
-
senderVk
|
|
3705
|
+
senderVk,
|
|
3706
|
+
{ replyTo: msg.messageId }
|
|
2191
3707
|
);
|
|
2192
3708
|
const wire = toWireDict(envelope);
|
|
2193
3709
|
await this._transport.send(wire);
|
|
@@ -2197,26 +3713,25 @@ var Agent = class {
|
|
|
2197
3713
|
/**
|
|
2198
3714
|
* Register with the relay server.
|
|
2199
3715
|
*
|
|
2200
|
-
* Calls POST /api/v1/register with agent name and public key.
|
|
2201
|
-
*
|
|
3716
|
+
* Calls POST /api/v1/register with agent name and public key. If the
|
|
3717
|
+
* address exists (409), repeats it with a proof that this agent holds the
|
|
3718
|
+
* key. Stores the returned API key on disk.
|
|
2202
3719
|
*/
|
|
2203
3720
|
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
|
-
);
|
|
3721
|
+
const body = {
|
|
3722
|
+
agent_name: this._config.name,
|
|
3723
|
+
public_key: serializeVerifyKey(this._keyManager.verifyKey)
|
|
3724
|
+
};
|
|
3725
|
+
let resp = await this._postRegistration(body);
|
|
2216
3726
|
if (resp.status === 409) {
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
3727
|
+
const address3 = await this._addressOnRelay();
|
|
3728
|
+
resp = await this._postRegistration({
|
|
3729
|
+
...body,
|
|
3730
|
+
proof: this._registrationProof(address3)
|
|
3731
|
+
});
|
|
3732
|
+
if (resp.status === 409 || resp.status === 403) {
|
|
3733
|
+
throw this._ownershipNotProven(address3);
|
|
3734
|
+
}
|
|
2220
3735
|
}
|
|
2221
3736
|
if (!resp.ok) {
|
|
2222
3737
|
const text = await resp.text();
|
|
@@ -2225,20 +3740,157 @@ var Agent = class {
|
|
|
2225
3740
|
);
|
|
2226
3741
|
}
|
|
2227
3742
|
const data = await resp.json();
|
|
2228
|
-
|
|
3743
|
+
const address2 = data["address"];
|
|
3744
|
+
this._useAddress(address2);
|
|
2229
3745
|
this._token = data["token"];
|
|
2230
|
-
|
|
3746
|
+
const { name } = this._config;
|
|
3747
|
+
const token = this._token;
|
|
3748
|
+
this._tryToSave("token", () => this._keyManager.saveToken(name, token));
|
|
3749
|
+
this._saveAddress(address2);
|
|
3750
|
+
}
|
|
3751
|
+
/** This agent's address on the relay, for signing a proof. */
|
|
3752
|
+
async _addressOnRelay() {
|
|
3753
|
+
const { name, explicitRelayDomain, relayDomain, relayUrl } = this._config;
|
|
3754
|
+
if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
|
|
3755
|
+
const domain2 = await this._fetchRelayDomain();
|
|
3756
|
+
if (domain2 === null) return `${name}::${relayDomain}`;
|
|
3757
|
+
if (!this._relayMayUse(domain2)) {
|
|
3758
|
+
throw new UAMError(
|
|
3759
|
+
`The relay at ${relayUrl} says its domain is ${domain2}. Pass the domain option as ${JSON.stringify(domain2)} if that is right.`
|
|
3760
|
+
);
|
|
3761
|
+
}
|
|
3762
|
+
return `${name}::${domain2}`;
|
|
3763
|
+
}
|
|
3764
|
+
/**
|
|
3765
|
+
* True if the relay's URL fits `domain`, so a proof can be signed for it.
|
|
3766
|
+
*
|
|
3767
|
+
* A proof names an address on one relay, so it is only signed for a domain
|
|
3768
|
+
* this URL belongs to. A relay on this machine may use any domain.
|
|
3769
|
+
*/
|
|
3770
|
+
_relayMayUse(domain2) {
|
|
3771
|
+
let host;
|
|
3772
|
+
try {
|
|
3773
|
+
host = new URL(this._config.relayUrl).hostname.toLowerCase();
|
|
3774
|
+
} catch {
|
|
3775
|
+
return false;
|
|
3776
|
+
}
|
|
3777
|
+
const wanted = domain2.toLowerCase();
|
|
3778
|
+
return host === wanted || host.endsWith(`.${wanted}`) || host === "localhost" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
|
|
3779
|
+
}
|
|
3780
|
+
_postRegistration(body) {
|
|
3781
|
+
return fetch(`${this._config.relayUrl}/api/v1/register`, {
|
|
3782
|
+
method: "POST",
|
|
3783
|
+
headers: { "Content-Type": "application/json" },
|
|
3784
|
+
body: JSON.stringify(body)
|
|
3785
|
+
});
|
|
3786
|
+
}
|
|
3787
|
+
/**
|
|
3788
|
+
* Sign a statement that this agent holds the key for `address`.
|
|
3789
|
+
*
|
|
3790
|
+
* The signed string must stay byte-identical to the Python SDK's.
|
|
3791
|
+
*/
|
|
3792
|
+
_registrationProof(address2) {
|
|
3793
|
+
const timestamp = utcTimestamp();
|
|
3794
|
+
const signature = signMessage(
|
|
3795
|
+
new TextEncoder().encode(`uam-register-v1
|
|
3796
|
+
${address2}
|
|
3797
|
+
${timestamp}`),
|
|
3798
|
+
this._keyManager.signingKey
|
|
3799
|
+
);
|
|
3800
|
+
return { timestamp, signature };
|
|
3801
|
+
}
|
|
3802
|
+
_ownershipNotProven(address2) {
|
|
3803
|
+
return new UAMError(
|
|
3804
|
+
`Address already registered, and this key could not prove ownership: ` + address2
|
|
3805
|
+
);
|
|
3806
|
+
}
|
|
3807
|
+
/** Remember `address` for this relay, if the key folder allows it. */
|
|
3808
|
+
_saveAddress(address2) {
|
|
3809
|
+
const { name, relayUrl } = this._config;
|
|
3810
|
+
this._tryToSave(
|
|
3811
|
+
"address",
|
|
3812
|
+
() => this._keyManager.saveAddress(name, address2, relayUrl)
|
|
3813
|
+
);
|
|
3814
|
+
}
|
|
3815
|
+
/** Saving is best effort: a key folder we can't write doesn't stop us. */
|
|
3816
|
+
_tryToSave(what, save) {
|
|
3817
|
+
try {
|
|
3818
|
+
save();
|
|
3819
|
+
} catch (err) {
|
|
3820
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3821
|
+
log.warn(`Could not save the ${what}: ${reason}`);
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
/** Take `address` as ours; its domain is this relay's real domain. */
|
|
3825
|
+
_useAddress(address2) {
|
|
3826
|
+
this._address = address2;
|
|
3827
|
+
const domain2 = parseAddress(address2).domain;
|
|
3828
|
+
this._config.relayDomain = domain2;
|
|
3829
|
+
if (this._resolver instanceof SmartResolver) {
|
|
3830
|
+
this._resolver.relayDomain = domain2;
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
/**
|
|
3834
|
+
* The address the stored token belongs to.
|
|
3835
|
+
*
|
|
3836
|
+
* An explicit domain settles it. Otherwise the relay chooses the domain,
|
|
3837
|
+
* and it need not be the relay's hostname: use the address saved for this
|
|
3838
|
+
* relay, or ask the relay once and keep the answer.
|
|
3839
|
+
*/
|
|
3840
|
+
async _storedAddress() {
|
|
3841
|
+
const { name, explicitRelayDomain } = this._config;
|
|
3842
|
+
if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
|
|
3843
|
+
const saved = this._keyManager.loadAddress(name, this._config.relayUrl);
|
|
3844
|
+
if (saved) return saved;
|
|
3845
|
+
const domain2 = await this._fetchRelayDomain();
|
|
3846
|
+
if (domain2 === null) return `${name}::${this._config.relayDomain}`;
|
|
3847
|
+
const address2 = `${name}::${domain2}`;
|
|
3848
|
+
this._saveAddress(address2);
|
|
3849
|
+
return address2;
|
|
3850
|
+
}
|
|
3851
|
+
/** Ask the relay for its domain; null if it cannot say. */
|
|
3852
|
+
async _fetchRelayDomain() {
|
|
3853
|
+
const url = `${this._config.relayUrl}/.well-known/uam-relay.json`;
|
|
3854
|
+
try {
|
|
3855
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(1e4) });
|
|
3856
|
+
if (!resp.ok) return null;
|
|
3857
|
+
const data = await resp.json();
|
|
3858
|
+
const domain2 = data["relay_domain"];
|
|
3859
|
+
if (typeof domain2 !== "string" || domain2 === "") return null;
|
|
3860
|
+
return parseAddress(`${this._config.name}::${domain2}`).domain;
|
|
3861
|
+
} catch {
|
|
3862
|
+
return null;
|
|
3863
|
+
}
|
|
2231
3864
|
}
|
|
2232
3865
|
};
|
|
2233
3866
|
|
|
2234
3867
|
// src/sdk/webhook-verify.ts
|
|
2235
|
-
var
|
|
3868
|
+
var import_node_crypto5 = require("crypto");
|
|
3869
|
+
function verifyWebhookSignature(payload, signatureHeader, secret2) {
|
|
3870
|
+
if (!signatureHeader.startsWith("sha256=")) {
|
|
3871
|
+
return false;
|
|
3872
|
+
}
|
|
3873
|
+
const expected = (0, import_node_crypto5.createHmac)("sha256", secret2).update(payload).digest("hex");
|
|
3874
|
+
const received = signatureHeader.slice("sha256=".length);
|
|
3875
|
+
const expectedBuf = Buffer.from(expected, "utf-8");
|
|
3876
|
+
const receivedBuf = Buffer.from(received, "utf-8");
|
|
3877
|
+
if (expectedBuf.length !== receivedBuf.length) {
|
|
3878
|
+
return false;
|
|
3879
|
+
}
|
|
3880
|
+
return (0, import_node_crypto5.timingSafeEqual)(expectedBuf, receivedBuf);
|
|
3881
|
+
}
|
|
2236
3882
|
|
|
2237
3883
|
// src/sdk/index.ts
|
|
2238
3884
|
init_dns_verifier();
|
|
2239
3885
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2240
3886
|
0 && (module.exports = {
|
|
2241
3887
|
Agent,
|
|
2242
|
-
|
|
3888
|
+
ContactBook,
|
|
3889
|
+
PENDING_NOTE,
|
|
3890
|
+
SEND_STATUSES,
|
|
3891
|
+
pendingNote,
|
|
3892
|
+
protocol,
|
|
3893
|
+
supports,
|
|
3894
|
+
verifyWebhookSignature
|
|
2243
3895
|
});
|
|
2244
3896
|
//# sourceMappingURL=index.cjs.map
|