@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.js
CHANGED
|
@@ -39,11 +39,11 @@ function extractPublicKey(tags) {
|
|
|
39
39
|
}
|
|
40
40
|
return null;
|
|
41
41
|
}
|
|
42
|
-
async function queryUamTxt(
|
|
42
|
+
async function queryUamTxt(domain2, timeout) {
|
|
43
43
|
const controller = new AbortController();
|
|
44
44
|
const timer = timeout != null ? setTimeout(() => controller.abort(), timeout) : void 0;
|
|
45
45
|
try {
|
|
46
|
-
const records = await dnsResolveTxt(`_uam.${
|
|
46
|
+
const records = await dnsResolveTxt(`_uam.${domain2}`);
|
|
47
47
|
const results = [];
|
|
48
48
|
for (const parts of records) {
|
|
49
49
|
const txtValue = parts.join("");
|
|
@@ -58,8 +58,8 @@ async function queryUamTxt(domain, timeout) {
|
|
|
58
58
|
if (timer != null) clearTimeout(timer);
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
async function resolveKeyViaHttps(agentName,
|
|
62
|
-
const url = `https://${
|
|
61
|
+
async function resolveKeyViaHttps(agentName, domain2, timeout) {
|
|
62
|
+
const url = `https://${domain2}/.well-known/uam.json`;
|
|
63
63
|
try {
|
|
64
64
|
const resp = await fetch(url, {
|
|
65
65
|
signal: AbortSignal.timeout(timeout ?? 1e4)
|
|
@@ -88,6 +88,9 @@ var init_dns_verifier = __esm({
|
|
|
88
88
|
}
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
// src/sdk/agent.ts
|
|
92
|
+
import { join as join4 } from "path";
|
|
93
|
+
|
|
91
94
|
// src/protocol/index.ts
|
|
92
95
|
var protocol_exports = {};
|
|
93
96
|
__export(protocol_exports, {
|
|
@@ -96,31 +99,43 @@ __export(protocol_exports, {
|
|
|
96
99
|
EnvelopeTooLargeError: () => EnvelopeTooLargeError,
|
|
97
100
|
InvalidAddressError: () => InvalidAddressError,
|
|
98
101
|
InvalidContactCardError: () => InvalidContactCardError,
|
|
102
|
+
InvalidDidKeyError: () => InvalidDidKeyError,
|
|
99
103
|
InvalidEnvelopeError: () => InvalidEnvelopeError,
|
|
100
104
|
KeyPinningError: () => KeyPinningError,
|
|
105
|
+
MAX_ENVELOPE_AGE: () => MAX_ENVELOPE_AGE,
|
|
101
106
|
MAX_ENVELOPE_SIZE: () => MAX_ENVELOPE_SIZE,
|
|
102
107
|
MessageType: () => MessageType,
|
|
108
|
+
SendRefusedError: () => SendRefusedError,
|
|
103
109
|
SignatureError: () => SignatureError,
|
|
104
110
|
SignatureVerificationError: () => SignatureVerificationError,
|
|
111
|
+
SignedObjectError: () => SignedObjectError,
|
|
105
112
|
UAMError: () => UAMError,
|
|
106
113
|
UAM_VERSION: () => UAM_VERSION,
|
|
114
|
+
WebAuthnError: () => WebAuthnError,
|
|
115
|
+
approvalChallenge: () => approvalChallenge,
|
|
107
116
|
b64Decode: () => b64Decode,
|
|
108
117
|
b64Encode: () => b64Encode,
|
|
118
|
+
canonical: () => canonical,
|
|
109
119
|
canonicalize: () => canonicalize,
|
|
110
120
|
contactCardFromDict: () => contactCardFromDict,
|
|
111
121
|
contactCardToDict: () => contactCardToDict,
|
|
112
122
|
createContactCard: () => createContactCard,
|
|
113
123
|
createEnvelope: () => createEnvelope,
|
|
124
|
+
credentialDid: () => credentialDid,
|
|
125
|
+
decodeDidKey: () => decodeDidKey,
|
|
114
126
|
decryptPayload: () => decryptPayload,
|
|
115
127
|
decryptPayloadAnonymous: () => decryptPayloadAnonymous,
|
|
116
128
|
deserializeSigningKey: () => deserializeSigningKey,
|
|
117
129
|
deserializeVerifyKey: () => deserializeVerifyKey,
|
|
130
|
+
encodeDidKey: () => encodeDidKey,
|
|
118
131
|
encryptPayload: () => encryptPayload,
|
|
119
132
|
encryptPayloadAnonymous: () => encryptPayloadAnonymous,
|
|
120
133
|
fromWireDict: () => fromWireDict,
|
|
121
134
|
generateKeypair: () => generateKeypair,
|
|
122
135
|
generateNonce: () => generateNonce,
|
|
136
|
+
objectHash: () => objectHash,
|
|
123
137
|
parseAddress: () => parseAddress,
|
|
138
|
+
parseBody: () => parseBody,
|
|
124
139
|
publicKeyFingerprint: () => publicKeyFingerprint,
|
|
125
140
|
serializeSigningKey: () => serializeSigningKey,
|
|
126
141
|
serializeVerifyKey: () => serializeVerifyKey,
|
|
@@ -129,14 +144,18 @@ __export(protocol_exports, {
|
|
|
129
144
|
toWireDict: () => toWireDict,
|
|
130
145
|
utcTimestamp: () => utcTimestamp,
|
|
131
146
|
validateEnvelopeSize: () => validateEnvelopeSize,
|
|
147
|
+
validateSignedObject: () => validate,
|
|
148
|
+
verifyAssertion: () => verifyAssertion,
|
|
132
149
|
verifyContactCard: () => verifyContactCard,
|
|
133
150
|
verifyEnvelope: () => verifyEnvelope,
|
|
151
|
+
verifyRegistration: () => verifyRegistration,
|
|
134
152
|
verifySignature: () => verifySignature
|
|
135
153
|
});
|
|
136
154
|
|
|
137
155
|
// src/protocol/types.ts
|
|
138
156
|
var UAM_VERSION = "0.1";
|
|
139
157
|
var MAX_ENVELOPE_SIZE = 65536;
|
|
158
|
+
var MAX_ENVELOPE_AGE = 300;
|
|
140
159
|
var MessageType = /* @__PURE__ */ ((MessageType2) => {
|
|
141
160
|
MessageType2["MESSAGE"] = "message";
|
|
142
161
|
MessageType2["HANDSHAKE_REQUEST"] = "handshake.request";
|
|
@@ -224,6 +243,18 @@ var KeyPinningError = class _KeyPinningError extends UAMError {
|
|
|
224
243
|
Object.setPrototypeOf(this, _KeyPinningError.prototype);
|
|
225
244
|
}
|
|
226
245
|
};
|
|
246
|
+
var SendRefusedError = class extends UAMError {
|
|
247
|
+
messageId;
|
|
248
|
+
code;
|
|
249
|
+
detail;
|
|
250
|
+
constructor(messageId, code, detail) {
|
|
251
|
+
super(`Relay refused message ${messageId}: [${code}] ${detail ?? ""}`.trimEnd());
|
|
252
|
+
this.name = "SendRefusedError";
|
|
253
|
+
this.messageId = messageId;
|
|
254
|
+
this.code = code;
|
|
255
|
+
this.detail = detail;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
227
258
|
|
|
228
259
|
// src/protocol/address.ts
|
|
229
260
|
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])?)$/;
|
|
@@ -350,11 +381,11 @@ function encryptPayload(plaintext, senderSigningKey, recipientVerifyKey) {
|
|
|
350
381
|
try {
|
|
351
382
|
const senderCurve = sodium.crypto_sign_ed25519_sk_to_curve25519(senderSigningKey);
|
|
352
383
|
const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(recipientVerifyKey);
|
|
353
|
-
const
|
|
354
|
-
const encrypted = sodium.crypto_box_easy(plaintext,
|
|
355
|
-
const combined = new Uint8Array(
|
|
356
|
-
combined.set(
|
|
357
|
-
combined.set(encrypted,
|
|
384
|
+
const nonce2 = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
|
385
|
+
const encrypted = sodium.crypto_box_easy(plaintext, nonce2, recipientCurve, senderCurve);
|
|
386
|
+
const combined = new Uint8Array(nonce2.length + encrypted.length);
|
|
387
|
+
combined.set(nonce2, 0);
|
|
388
|
+
combined.set(encrypted, nonce2.length);
|
|
358
389
|
return b64Encode(combined);
|
|
359
390
|
} catch (err) {
|
|
360
391
|
if (err instanceof EncryptionError) throw err;
|
|
@@ -364,11 +395,11 @@ function encryptPayload(plaintext, senderSigningKey, recipientVerifyKey) {
|
|
|
364
395
|
function decryptPayload(ciphertextB64, recipientSigningKey, senderVerifyKey) {
|
|
365
396
|
try {
|
|
366
397
|
const combined = b64Decode(ciphertextB64);
|
|
367
|
-
const
|
|
398
|
+
const nonce2 = combined.slice(0, sodium.crypto_box_NONCEBYTES);
|
|
368
399
|
const ciphertext = combined.slice(sodium.crypto_box_NONCEBYTES);
|
|
369
400
|
const recipientCurve = sodium.crypto_sign_ed25519_sk_to_curve25519(recipientSigningKey);
|
|
370
401
|
const senderCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(senderVerifyKey);
|
|
371
|
-
return sodium.crypto_box_open_easy(ciphertext,
|
|
402
|
+
return sodium.crypto_box_open_easy(ciphertext, nonce2, senderCurve, recipientCurve);
|
|
372
403
|
} catch (err) {
|
|
373
404
|
if (err instanceof DecryptionError) throw err;
|
|
374
405
|
throw new DecryptionError(err instanceof Error ? err.message : String(err));
|
|
@@ -476,7 +507,7 @@ async function createEnvelope(fromAddress, toAddress, messageType, payloadPlaint
|
|
|
476
507
|
parseAddress(fromAddress);
|
|
477
508
|
parseAddress(toAddress);
|
|
478
509
|
const messageId = randomUUID();
|
|
479
|
-
const
|
|
510
|
+
const nonce2 = generateNonce();
|
|
480
511
|
const timestamp = utcTimestamp();
|
|
481
512
|
let encryptedPayload;
|
|
482
513
|
if (typeValue === "handshake.request" /* HANDSHAKE_REQUEST */) {
|
|
@@ -495,7 +526,7 @@ async function createEnvelope(fromAddress, toAddress, messageType, payloadPlaint
|
|
|
495
526
|
toAddress,
|
|
496
527
|
timestamp,
|
|
497
528
|
type: typeValue,
|
|
498
|
-
nonce,
|
|
529
|
+
nonce: nonce2,
|
|
499
530
|
payload: encryptedPayload,
|
|
500
531
|
signature: "",
|
|
501
532
|
threadId: options?.threadId,
|
|
@@ -514,7 +545,7 @@ async function createEnvelope(fromAddress, toAddress, messageType, payloadPlaint
|
|
|
514
545
|
toAddress,
|
|
515
546
|
timestamp,
|
|
516
547
|
type: typeValue,
|
|
517
|
-
nonce,
|
|
548
|
+
nonce: nonce2,
|
|
518
549
|
payload: encryptedPayload,
|
|
519
550
|
signature,
|
|
520
551
|
threadId: options?.threadId,
|
|
@@ -606,8 +637,8 @@ function contactCardFromDict(d, options) {
|
|
|
606
637
|
}
|
|
607
638
|
return card;
|
|
608
639
|
}
|
|
609
|
-
async function createContactCard(
|
|
610
|
-
parseAddress(
|
|
640
|
+
async function createContactCard(address2, displayName, relay, signingKey, options) {
|
|
641
|
+
parseAddress(address2);
|
|
611
642
|
const verifyKey = signingKey.slice(32);
|
|
612
643
|
const pubKeyB64 = serializeVerifyKey(verifyKey);
|
|
613
644
|
const fp = publicKeyFingerprint(verifyKey);
|
|
@@ -617,7 +648,7 @@ async function createContactCard(address, displayName, relay, signingKey, option
|
|
|
617
648
|
];
|
|
618
649
|
const tempCard = {
|
|
619
650
|
version: UAM_VERSION,
|
|
620
|
-
address,
|
|
651
|
+
address: address2,
|
|
621
652
|
displayName,
|
|
622
653
|
description: options?.description,
|
|
623
654
|
system: options?.system,
|
|
@@ -634,7 +665,7 @@ async function createContactCard(address, displayName, relay, signingKey, option
|
|
|
634
665
|
const signature = signMessage(canonicalize(signable), signingKey);
|
|
635
666
|
return Object.freeze({
|
|
636
667
|
version: UAM_VERSION,
|
|
637
|
-
address,
|
|
668
|
+
address: address2,
|
|
638
669
|
displayName,
|
|
639
670
|
description: options?.description,
|
|
640
671
|
system: options?.system,
|
|
@@ -661,10 +692,786 @@ function verifyContactCard(card) {
|
|
|
661
692
|
verifySignature(canonicalize(signable), card.signature, vk);
|
|
662
693
|
}
|
|
663
694
|
|
|
695
|
+
// src/protocol/signed.ts
|
|
696
|
+
import { createHash as createHash2 } from "crypto";
|
|
697
|
+
|
|
698
|
+
// src/protocol/didkey.ts
|
|
699
|
+
var B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
700
|
+
var PREFIX = {
|
|
701
|
+
ed25519: new Uint8Array([237, 1]),
|
|
702
|
+
p256: new Uint8Array([128, 36])
|
|
703
|
+
};
|
|
704
|
+
var LENGTH = { ed25519: 32, p256: 33 };
|
|
705
|
+
var InvalidDidKeyError = class extends UAMError {
|
|
706
|
+
constructor(message) {
|
|
707
|
+
super(message);
|
|
708
|
+
this.name = "InvalidDidKeyError";
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
function b58encode(data) {
|
|
712
|
+
let n = 0n;
|
|
713
|
+
for (const byte of data) n = n << 8n | BigInt(byte);
|
|
714
|
+
let out = "";
|
|
715
|
+
while (n > 0n) {
|
|
716
|
+
out = B58[Number(n % 58n)] + out;
|
|
717
|
+
n /= 58n;
|
|
718
|
+
}
|
|
719
|
+
let zeros = 0;
|
|
720
|
+
while (zeros < data.length && data[zeros] === 0) zeros += 1;
|
|
721
|
+
return "1".repeat(zeros) + out;
|
|
722
|
+
}
|
|
723
|
+
function b58decode(text) {
|
|
724
|
+
let n = 0n;
|
|
725
|
+
for (const c of text) {
|
|
726
|
+
const i = B58.indexOf(c);
|
|
727
|
+
if (i < 0) throw new InvalidDidKeyError(`not base58btc: ${JSON.stringify(c)}`);
|
|
728
|
+
n = n * 58n + BigInt(i);
|
|
729
|
+
}
|
|
730
|
+
const body = [];
|
|
731
|
+
while (n > 0n) {
|
|
732
|
+
body.unshift(Number(n & 0xffn));
|
|
733
|
+
n >>= 8n;
|
|
734
|
+
}
|
|
735
|
+
let zeros = 0;
|
|
736
|
+
while (zeros < text.length && text[zeros] === "1") zeros += 1;
|
|
737
|
+
return new Uint8Array([...new Array(zeros).fill(0), ...body]);
|
|
738
|
+
}
|
|
739
|
+
function checkKey(kind, key) {
|
|
740
|
+
if (key.length !== LENGTH[kind]) {
|
|
741
|
+
throw new InvalidDidKeyError(`${kind} key must be ${LENGTH[kind]} bytes`);
|
|
742
|
+
}
|
|
743
|
+
if (kind === "p256" && key[0] !== 2 && key[0] !== 3) {
|
|
744
|
+
throw new InvalidDidKeyError("P-256 key must be a compressed point");
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function encodeDidKey(kind, publicKey) {
|
|
748
|
+
if (!(kind in PREFIX)) throw new InvalidDidKeyError(`unsupported key type: ${kind}`);
|
|
749
|
+
checkKey(kind, publicKey);
|
|
750
|
+
return "did:key:z" + b58encode(new Uint8Array([...PREFIX[kind], ...publicKey]));
|
|
751
|
+
}
|
|
752
|
+
function decodeDidKey(did2) {
|
|
753
|
+
if (typeof did2 !== "string" || !did2.startsWith("did:key:z")) {
|
|
754
|
+
throw new InvalidDidKeyError("not a did:key with base58btc multibase");
|
|
755
|
+
}
|
|
756
|
+
const raw = b58decode(did2.slice("did:key:z".length));
|
|
757
|
+
for (const kind of Object.keys(PREFIX)) {
|
|
758
|
+
const prefix = PREFIX[kind];
|
|
759
|
+
if (raw.length >= prefix.length && prefix.every((b, i) => raw[i] === b)) {
|
|
760
|
+
const key = raw.slice(prefix.length);
|
|
761
|
+
checkKey(kind, key);
|
|
762
|
+
return { kind, key };
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
throw new InvalidDidKeyError("unsupported key type");
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// src/protocol/signed.ts
|
|
769
|
+
var MAX_INT = 2 ** 53 - 1;
|
|
770
|
+
var GRANT_MAX_DAYS = 90;
|
|
771
|
+
var INVITE_MAX_DAYS = 7;
|
|
772
|
+
var SignedObjectError = class extends UAMError {
|
|
773
|
+
constructor(message) {
|
|
774
|
+
super(message);
|
|
775
|
+
this.name = "SignedObjectError";
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
var has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
779
|
+
var Parser = class {
|
|
780
|
+
constructor(s) {
|
|
781
|
+
this.s = s;
|
|
782
|
+
}
|
|
783
|
+
i = 0;
|
|
784
|
+
fail(why) {
|
|
785
|
+
throw new SignedObjectError(`not JSON or outside the profile at ${this.i}: ${why}`);
|
|
786
|
+
}
|
|
787
|
+
ws() {
|
|
788
|
+
while (this.i < this.s.length && " \n\r".includes(this.s[this.i])) this.i += 1;
|
|
789
|
+
}
|
|
790
|
+
document() {
|
|
791
|
+
this.ws();
|
|
792
|
+
if (this.s[this.i] !== "{") this.fail("a body must be a JSON object");
|
|
793
|
+
const value = this.value();
|
|
794
|
+
this.ws();
|
|
795
|
+
if (this.i !== this.s.length) this.fail("trailing characters");
|
|
796
|
+
return value;
|
|
797
|
+
}
|
|
798
|
+
value() {
|
|
799
|
+
this.ws();
|
|
800
|
+
const c = this.s[this.i];
|
|
801
|
+
if (c === "{") return this.object();
|
|
802
|
+
if (c === "[") return this.array();
|
|
803
|
+
if (c === '"') return this.string();
|
|
804
|
+
if (c === "-" || c !== void 0 && c >= "0" && c <= "9") return this.number();
|
|
805
|
+
for (const [word, v] of [["true", true], ["false", false]]) {
|
|
806
|
+
if (this.s.startsWith(word, this.i)) {
|
|
807
|
+
this.i += word.length;
|
|
808
|
+
return v;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (this.s.startsWith("null", this.i)) this.fail("null is not allowed");
|
|
812
|
+
return this.fail("unexpected character");
|
|
813
|
+
}
|
|
814
|
+
object() {
|
|
815
|
+
this.i += 1;
|
|
816
|
+
const out = /* @__PURE__ */ Object.create(null);
|
|
817
|
+
this.ws();
|
|
818
|
+
if (this.s[this.i] === "}") {
|
|
819
|
+
this.i += 1;
|
|
820
|
+
return out;
|
|
821
|
+
}
|
|
822
|
+
for (; ; ) {
|
|
823
|
+
this.ws();
|
|
824
|
+
if (this.s[this.i] !== '"') this.fail("expected a key");
|
|
825
|
+
const key = this.string();
|
|
826
|
+
if (has(out, key)) this.fail(`duplicate key: ${JSON.stringify(key)}`);
|
|
827
|
+
this.ws();
|
|
828
|
+
if (this.s[this.i] !== ":") this.fail("expected ':'");
|
|
829
|
+
this.i += 1;
|
|
830
|
+
Object.defineProperty(out, key, {
|
|
831
|
+
value: this.value(),
|
|
832
|
+
enumerable: true,
|
|
833
|
+
writable: true,
|
|
834
|
+
configurable: true
|
|
835
|
+
});
|
|
836
|
+
this.ws();
|
|
837
|
+
if (this.s[this.i] === ",") {
|
|
838
|
+
this.i += 1;
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
if (this.s[this.i] === "}") {
|
|
842
|
+
this.i += 1;
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
this.fail("expected ',' or '}'");
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
array() {
|
|
849
|
+
this.i += 1;
|
|
850
|
+
const out = [];
|
|
851
|
+
this.ws();
|
|
852
|
+
if (this.s[this.i] === "]") {
|
|
853
|
+
this.i += 1;
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
for (; ; ) {
|
|
857
|
+
out.push(this.value());
|
|
858
|
+
this.ws();
|
|
859
|
+
if (this.s[this.i] === ",") {
|
|
860
|
+
this.i += 1;
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (this.s[this.i] === "]") {
|
|
864
|
+
this.i += 1;
|
|
865
|
+
return out;
|
|
866
|
+
}
|
|
867
|
+
this.fail("expected ',' or ']'");
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
string() {
|
|
871
|
+
this.i += 1;
|
|
872
|
+
let out = "";
|
|
873
|
+
for (; ; ) {
|
|
874
|
+
const c = this.s[this.i];
|
|
875
|
+
if (c === void 0) this.fail("unterminated string");
|
|
876
|
+
this.i += 1;
|
|
877
|
+
if (c === '"') break;
|
|
878
|
+
if (c === "\\") {
|
|
879
|
+
const e = this.s[this.i];
|
|
880
|
+
this.i += 1;
|
|
881
|
+
const simple = {
|
|
882
|
+
'"': '"',
|
|
883
|
+
"\\": "\\",
|
|
884
|
+
"/": "/",
|
|
885
|
+
b: "\b",
|
|
886
|
+
f: "\f",
|
|
887
|
+
n: "\n",
|
|
888
|
+
r: "\r",
|
|
889
|
+
t: " "
|
|
890
|
+
};
|
|
891
|
+
if (e !== void 0 && has(simple, e)) {
|
|
892
|
+
out += simple[e];
|
|
893
|
+
} else if (e === "u") {
|
|
894
|
+
const hex = this.s.slice(this.i, this.i + 4);
|
|
895
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) this.fail("bad \\u escape");
|
|
896
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
897
|
+
this.i += 4;
|
|
898
|
+
} else {
|
|
899
|
+
this.fail("bad escape");
|
|
900
|
+
}
|
|
901
|
+
} else {
|
|
902
|
+
if (c < " ") this.fail("control character in string");
|
|
903
|
+
out += c;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
checkString(out);
|
|
907
|
+
return out;
|
|
908
|
+
}
|
|
909
|
+
number() {
|
|
910
|
+
const m = /^-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?/.exec(this.s.slice(this.i));
|
|
911
|
+
if (!m) return this.fail("bad number");
|
|
912
|
+
const text = m[0];
|
|
913
|
+
if (m[2] !== void 0 || m[3] !== void 0) this.fail(`floats are not allowed: ${text}`);
|
|
914
|
+
if (text === "-0") this.fail("-0 is not allowed");
|
|
915
|
+
const big = BigInt(text);
|
|
916
|
+
if (big > BigInt(MAX_INT) || big < -BigInt(MAX_INT)) this.fail(`integer out of range: ${text}`);
|
|
917
|
+
this.i += text.length;
|
|
918
|
+
return Number(big);
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
function parseBody(text) {
|
|
922
|
+
let s;
|
|
923
|
+
if (typeof text === "string") {
|
|
924
|
+
s = text;
|
|
925
|
+
} else {
|
|
926
|
+
try {
|
|
927
|
+
s = new TextDecoder("utf-8", { fatal: true }).decode(text);
|
|
928
|
+
} catch {
|
|
929
|
+
throw new SignedObjectError("body is not UTF-8");
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return new Parser(s).document();
|
|
933
|
+
}
|
|
934
|
+
function checkString(value) {
|
|
935
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
936
|
+
const code = value.charCodeAt(i);
|
|
937
|
+
if (code < 32 || code > 126) {
|
|
938
|
+
throw new SignedObjectError("strings must be printable ASCII");
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
function isPlainObject(value) {
|
|
943
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
944
|
+
const proto = Object.getPrototypeOf(value);
|
|
945
|
+
return proto === null || proto === Object.prototype;
|
|
946
|
+
}
|
|
947
|
+
function encode(value) {
|
|
948
|
+
if (value === true) return "true";
|
|
949
|
+
if (value === false) return "false";
|
|
950
|
+
if (typeof value === "number") {
|
|
951
|
+
if (!Number.isSafeInteger(value) || Object.is(value, -0)) {
|
|
952
|
+
throw new SignedObjectError(`not an integer in range: ${value}`);
|
|
953
|
+
}
|
|
954
|
+
return String(value);
|
|
955
|
+
}
|
|
956
|
+
if (typeof value === "string") {
|
|
957
|
+
checkString(value);
|
|
958
|
+
return '"' + value.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
|
|
959
|
+
}
|
|
960
|
+
if (Array.isArray(value)) return "[" + value.map(encode).join(",") + "]";
|
|
961
|
+
if (isPlainObject(value)) {
|
|
962
|
+
return "{" + Object.keys(value).sort().map((key) => {
|
|
963
|
+
checkString(key);
|
|
964
|
+
return encode(key) + ":" + encode(value[key]);
|
|
965
|
+
}).join(",") + "}";
|
|
966
|
+
}
|
|
967
|
+
if (value === null) throw new SignedObjectError("null is not allowed");
|
|
968
|
+
throw new SignedObjectError(`not allowed: ${typeof value}`);
|
|
969
|
+
}
|
|
970
|
+
function canonical(body) {
|
|
971
|
+
if (!isPlainObject(body)) throw new SignedObjectError("a body must be a JSON object");
|
|
972
|
+
return new TextEncoder().encode(encode(body));
|
|
973
|
+
}
|
|
974
|
+
function hashCanonical(body) {
|
|
975
|
+
return createHash2("sha256").update(`UAM-${String(body["type"])}-v1
|
|
976
|
+
`).update(canonical(body)).digest("base64url");
|
|
977
|
+
}
|
|
978
|
+
function objectHash(body) {
|
|
979
|
+
validate(body);
|
|
980
|
+
return hashCanonical(body);
|
|
981
|
+
}
|
|
982
|
+
var TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/;
|
|
983
|
+
var HASH_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
984
|
+
var OWNER_ID_RE = /^[A-Za-z0-9_-]{22}$/;
|
|
985
|
+
var NONCE_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
986
|
+
var SECRET_RE = /^[A-Za-z0-9_-]{22}$/;
|
|
987
|
+
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])?)+$/;
|
|
988
|
+
function fail(path, why) {
|
|
989
|
+
throw new SignedObjectError(`${path}: ${why}`);
|
|
990
|
+
}
|
|
991
|
+
var string_ = (v, p) => {
|
|
992
|
+
if (typeof v !== "string" || v.length === 0) fail(p, "must be a non-empty string");
|
|
993
|
+
};
|
|
994
|
+
var label = (v, p) => {
|
|
995
|
+
string_(v, p);
|
|
996
|
+
if (v.length > 64) fail(p, "at most 64 characters");
|
|
997
|
+
if (v.includes('"')) fail(p, 'must not contain "');
|
|
998
|
+
};
|
|
999
|
+
var matching = (re, what) => (v, p) => {
|
|
1000
|
+
if (typeof v !== "string" || !re.test(v)) fail(p, `must be ${what}`);
|
|
1001
|
+
};
|
|
1002
|
+
var hash = matching(HASH_RE, "a hash (43 base64url characters)");
|
|
1003
|
+
var ownerId = matching(OWNER_ID_RE, "an owner id (22 base64url characters)");
|
|
1004
|
+
var nonce = matching(NONCE_RE, "a nonce (16-64 base64url characters)");
|
|
1005
|
+
var secret = matching(SECRET_RE, "a secret (22 base64url characters)");
|
|
1006
|
+
var domain = matching(DOMAIN_RE, "a lowercase domain");
|
|
1007
|
+
function parseTime(value) {
|
|
1008
|
+
const m = TIME_RE.exec(value);
|
|
1009
|
+
if (!m) return null;
|
|
1010
|
+
const [y, mo, d, h, mi, s] = m.slice(1).map(Number);
|
|
1011
|
+
const t = Date.UTC(y, mo - 1, d, h, mi, s);
|
|
1012
|
+
const back = new Date(t);
|
|
1013
|
+
if (back.getUTCFullYear() !== y || back.getUTCMonth() !== mo - 1 || back.getUTCDate() !== d || back.getUTCHours() !== h || back.getUTCMinutes() !== mi || back.getUTCSeconds() !== s) {
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
return t;
|
|
1017
|
+
}
|
|
1018
|
+
var time = (v, p) => {
|
|
1019
|
+
if (typeof v !== "string" || !TIME_RE.test(v)) fail(p, "must be YYYY-MM-DDTHH:MM:SSZ");
|
|
1020
|
+
if (parseTime(v) === null) fail(p, "not a real time");
|
|
1021
|
+
};
|
|
1022
|
+
var bool = (v, p) => {
|
|
1023
|
+
if (typeof v !== "boolean") fail(p, "must be true or false");
|
|
1024
|
+
};
|
|
1025
|
+
var constant = (expected) => (v, p) => {
|
|
1026
|
+
if (v !== expected) fail(p, `must be ${JSON.stringify(expected)}`);
|
|
1027
|
+
};
|
|
1028
|
+
var oneOf = (...options) => (v, p) => {
|
|
1029
|
+
if (typeof v !== "string" || !options.includes(v)) fail(p, `must be one of ${options.join(", ")}`);
|
|
1030
|
+
};
|
|
1031
|
+
var uint = (v, p) => {
|
|
1032
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0) {
|
|
1033
|
+
fail(p, "must be a non-negative integer");
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
var address = (v, p) => {
|
|
1037
|
+
if (typeof v !== "string") fail(p, "must be an address");
|
|
1038
|
+
let full;
|
|
1039
|
+
try {
|
|
1040
|
+
full = parseAddress(v).full;
|
|
1041
|
+
} catch {
|
|
1042
|
+
return fail(p, "must be an address");
|
|
1043
|
+
}
|
|
1044
|
+
if (full !== v) fail(p, "must be an address in canonical (lowercase) form");
|
|
1045
|
+
};
|
|
1046
|
+
var did = (kind) => (v, p) => {
|
|
1047
|
+
let found;
|
|
1048
|
+
try {
|
|
1049
|
+
found = decodeDidKey(v).kind;
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
return fail(p, err instanceof Error ? err.message : String(err));
|
|
1052
|
+
}
|
|
1053
|
+
if (found !== kind) fail(p, `must be a ${kind} did:key`);
|
|
1054
|
+
};
|
|
1055
|
+
var list = (item, minLen = 1, maxLen = 64) => (v, p) => {
|
|
1056
|
+
if (!Array.isArray(v) || v.length < minLen || v.length > maxLen) {
|
|
1057
|
+
fail(p, `must be a list of ${minLen}-${maxLen} items`);
|
|
1058
|
+
}
|
|
1059
|
+
v.forEach((entry, i) => item(entry, `${p}[${i}]`));
|
|
1060
|
+
};
|
|
1061
|
+
var object = (required, optional = {}) => (v, p) => {
|
|
1062
|
+
if (!isPlainObject(v)) fail(p, "must be an object");
|
|
1063
|
+
for (const key of Object.keys(v)) {
|
|
1064
|
+
if (!has(required, key) && !has(optional, key)) fail(`${p}.${key}`, "unknown member");
|
|
1065
|
+
}
|
|
1066
|
+
for (const [key, member] of Object.entries(required)) {
|
|
1067
|
+
if (!has(v, key)) fail(`${p}.${key}`, "missing");
|
|
1068
|
+
member(v[key], `${p}.${key}`);
|
|
1069
|
+
}
|
|
1070
|
+
for (const [key, member] of Object.entries(optional)) {
|
|
1071
|
+
if (has(v, key)) member(v[key], `${p}.${key}`);
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
var location = (v, p) => {
|
|
1075
|
+
if (v !== "shared") address(v, p);
|
|
1076
|
+
};
|
|
1077
|
+
var access = object({ actions: list(oneOf("message"), 1, 1), locations: list(location) });
|
|
1078
|
+
var ownerRef = object({ relay: domain, ownerId, keysHash: hash });
|
|
1079
|
+
var ownerRefNoKeys = object({ relay: domain, ownerId });
|
|
1080
|
+
var passkey = did("p256");
|
|
1081
|
+
var agentKey = did("ed25519");
|
|
1082
|
+
var keysOwner = object({ relay: domain }, { ownerId });
|
|
1083
|
+
var EXT = { ext: () => void 0, crit: () => void 0 };
|
|
1084
|
+
function checkExt(body) {
|
|
1085
|
+
if (has(body, "ext") && !isPlainObject(body["ext"])) fail("ext", "must be an object");
|
|
1086
|
+
if (has(body, "crit")) {
|
|
1087
|
+
const crit = body["crit"];
|
|
1088
|
+
if (!has(body, "ext")) fail("crit", "needs ext");
|
|
1089
|
+
if (!Array.isArray(crit) || !crit.every((k) => typeof k === "string")) {
|
|
1090
|
+
fail("crit", "must be a list of ext keys");
|
|
1091
|
+
}
|
|
1092
|
+
for (const key of crit) {
|
|
1093
|
+
if (!has(body["ext"], key)) fail(`crit.${key}`, "not in ext");
|
|
1094
|
+
fail(`crit.${key}`, "unknown critical extension");
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
function span(body, from, to) {
|
|
1099
|
+
return parseTime(body[to]) - parseTime(body[from]);
|
|
1100
|
+
}
|
|
1101
|
+
var DAY = 24 * 60 * 60 * 1e3;
|
|
1102
|
+
var SCHEMAS = {
|
|
1103
|
+
UamOwnerKeys(body) {
|
|
1104
|
+
object(
|
|
1105
|
+
{
|
|
1106
|
+
type: constant("UamOwnerKeys"),
|
|
1107
|
+
v: constant(1),
|
|
1108
|
+
owner: keysOwner,
|
|
1109
|
+
seq: uint,
|
|
1110
|
+
keys: list(object({ id: passkey, rpId: domain, backupEligible: bool, label }), 1, 16)
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
prev: hash,
|
|
1114
|
+
removed: list(object({ id: passkey, reason: oneOf("removed", "compromised") }), 1, 64),
|
|
1115
|
+
recovery: bool,
|
|
1116
|
+
...EXT
|
|
1117
|
+
}
|
|
1118
|
+
)(body, "$");
|
|
1119
|
+
const owner = body["owner"];
|
|
1120
|
+
if (body["seq"] === 0) {
|
|
1121
|
+
if (has(owner, "ownerId") || has(body, "prev")) fail("$", "entry 0 has no owner.ownerId and no prev");
|
|
1122
|
+
} else if (!has(owner, "ownerId") || !has(body, "prev")) {
|
|
1123
|
+
fail("$", "a later entry needs owner.ownerId and prev");
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
1126
|
+
UamOwnerLink(body) {
|
|
1127
|
+
object({
|
|
1128
|
+
type: constant("UamOwnerLink"),
|
|
1129
|
+
v: constant(1),
|
|
1130
|
+
owner: ownerRef,
|
|
1131
|
+
agent: address,
|
|
1132
|
+
agentKey,
|
|
1133
|
+
relay: domain,
|
|
1134
|
+
via: oneOf("setup", "link"),
|
|
1135
|
+
shared: bool,
|
|
1136
|
+
effectiveAt: time,
|
|
1137
|
+
validFrom: time,
|
|
1138
|
+
nonce
|
|
1139
|
+
}, { ...EXT })(body, "$");
|
|
1140
|
+
},
|
|
1141
|
+
UamOwnerUnlink(body) {
|
|
1142
|
+
object({
|
|
1143
|
+
type: constant("UamOwnerUnlink"),
|
|
1144
|
+
v: constant(1),
|
|
1145
|
+
owner: ownerRef,
|
|
1146
|
+
agent: address,
|
|
1147
|
+
agentKey,
|
|
1148
|
+
reason: oneOf("owner", "admin", "relink", "authority", "lapsed"),
|
|
1149
|
+
validFrom: time,
|
|
1150
|
+
nonce
|
|
1151
|
+
}, { ...EXT })(body, "$");
|
|
1152
|
+
},
|
|
1153
|
+
UamInvite(body) {
|
|
1154
|
+
object(
|
|
1155
|
+
{
|
|
1156
|
+
type: constant("UamInvite"),
|
|
1157
|
+
v: constant(1),
|
|
1158
|
+
inviter: ownerRef,
|
|
1159
|
+
offer: access,
|
|
1160
|
+
ask: access,
|
|
1161
|
+
secretHash: hash,
|
|
1162
|
+
mode: oneOf("preapproved", "confirm"),
|
|
1163
|
+
maxUses: constant(1),
|
|
1164
|
+
validFrom: time,
|
|
1165
|
+
redeemBy: time,
|
|
1166
|
+
nonce
|
|
1167
|
+
},
|
|
1168
|
+
{ emailHash: hash, ...EXT }
|
|
1169
|
+
)(body, "$");
|
|
1170
|
+
if (body["mode"] === "preapproved" && !has(body, "emailHash")) {
|
|
1171
|
+
fail("$.emailHash", "a pre-approved invite names the invitee's email");
|
|
1172
|
+
}
|
|
1173
|
+
const d = span(body, "validFrom", "redeemBy");
|
|
1174
|
+
if (!(d > 0 && d <= INVITE_MAX_DAYS * DAY)) {
|
|
1175
|
+
fail("$.redeemBy", `must be after validFrom, within ${INVITE_MAX_DAYS} days`);
|
|
1176
|
+
}
|
|
1177
|
+
},
|
|
1178
|
+
UamRedemption(body) {
|
|
1179
|
+
object({
|
|
1180
|
+
type: constant("UamRedemption"),
|
|
1181
|
+
v: constant(1),
|
|
1182
|
+
invite: hash,
|
|
1183
|
+
secret,
|
|
1184
|
+
invitee: ownerRef,
|
|
1185
|
+
issuer: passkey,
|
|
1186
|
+
createdAt: time,
|
|
1187
|
+
nonce
|
|
1188
|
+
}, { ...EXT })(body, "$");
|
|
1189
|
+
},
|
|
1190
|
+
UamContactGrant(body) {
|
|
1191
|
+
object({
|
|
1192
|
+
type: constant("UamContactGrant"),
|
|
1193
|
+
v: constant(1),
|
|
1194
|
+
issuer: passkey,
|
|
1195
|
+
issuerOwner: ownerRefNoKeys,
|
|
1196
|
+
credentialSubject: subject,
|
|
1197
|
+
authorization_details: list(object({
|
|
1198
|
+
type: constant("uam_contact"),
|
|
1199
|
+
actions: list(oneOf("message"), 1, 1),
|
|
1200
|
+
locations: list(location)
|
|
1201
|
+
}), 1, 8),
|
|
1202
|
+
via: oneOf("intro_email", "intro_code", "intro_qr", "request", "carried_over"),
|
|
1203
|
+
enforcer: domain,
|
|
1204
|
+
validFrom: time,
|
|
1205
|
+
validUntil: time,
|
|
1206
|
+
nonce
|
|
1207
|
+
}, { ...EXT })(body, "$");
|
|
1208
|
+
const d = span(body, "validFrom", "validUntil");
|
|
1209
|
+
if (!(d > 0 && d <= GRANT_MAX_DAYS * DAY)) {
|
|
1210
|
+
fail("$.validUntil", `must be after validFrom, within ${GRANT_MAX_DAYS} days`);
|
|
1211
|
+
}
|
|
1212
|
+
},
|
|
1213
|
+
// Who may message an agent, as its owner approved it (P2e). Only a change
|
|
1214
|
+
// that widens it is signed; narrowing needs no passkey.
|
|
1215
|
+
UamAgentPolicy(body) {
|
|
1216
|
+
object({
|
|
1217
|
+
type: constant("UamAgentPolicy"),
|
|
1218
|
+
v: constant(1),
|
|
1219
|
+
owner: ownerRef,
|
|
1220
|
+
agent: address,
|
|
1221
|
+
agentKey,
|
|
1222
|
+
policy: oneOf("open", "contacts"),
|
|
1223
|
+
sameOwner: bool,
|
|
1224
|
+
validFrom: time,
|
|
1225
|
+
nonce
|
|
1226
|
+
}, { ...EXT })(body, "$");
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
var SUBJECTS = {
|
|
1230
|
+
owner: object({ kind: constant("owner"), owner: ownerRef }),
|
|
1231
|
+
invitee: object({ kind: constant("invitee"), invite: hash }),
|
|
1232
|
+
agent: object({ kind: constant("agent"), id: agentKey, address })
|
|
1233
|
+
};
|
|
1234
|
+
function subject(value, path) {
|
|
1235
|
+
const kind = isPlainObject(value) ? value["kind"] : void 0;
|
|
1236
|
+
if (typeof kind !== "string" || !has(SUBJECTS, kind)) {
|
|
1237
|
+
fail(`${path}.kind`, `must be one of ${Object.keys(SUBJECTS).join(", ")}`);
|
|
1238
|
+
}
|
|
1239
|
+
SUBJECTS[kind](value, path);
|
|
1240
|
+
}
|
|
1241
|
+
function validate(body) {
|
|
1242
|
+
if (!isPlainObject(body)) throw new SignedObjectError("a body must be a JSON object");
|
|
1243
|
+
encode(body);
|
|
1244
|
+
const type = body["type"];
|
|
1245
|
+
if (typeof type !== "string" || !has(SCHEMAS, type)) {
|
|
1246
|
+
throw new SignedObjectError(`unknown type: ${JSON.stringify(type)}`);
|
|
1247
|
+
}
|
|
1248
|
+
SCHEMAS[type](body);
|
|
1249
|
+
checkExt(body);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// src/protocol/webauthn.ts
|
|
1253
|
+
import { createHash as createHash3 } from "crypto";
|
|
1254
|
+
import { p256 } from "@noble/curves/p256";
|
|
1255
|
+
var ES256 = -7;
|
|
1256
|
+
var UP = 1;
|
|
1257
|
+
var UV = 4;
|
|
1258
|
+
var BE = 8;
|
|
1259
|
+
var AT = 64;
|
|
1260
|
+
var ED = 128;
|
|
1261
|
+
var WebAuthnError = class extends UAMError {
|
|
1262
|
+
constructor(message) {
|
|
1263
|
+
super(message);
|
|
1264
|
+
this.name = "WebAuthnError";
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
function credentialDid(credential) {
|
|
1268
|
+
return encodeDidKey("p256", credential.publicKey);
|
|
1269
|
+
}
|
|
1270
|
+
var sha256 = (...parts) => {
|
|
1271
|
+
const h = createHash3("sha256");
|
|
1272
|
+
for (const part of parts) h.update(part);
|
|
1273
|
+
return new Uint8Array(h.digest());
|
|
1274
|
+
};
|
|
1275
|
+
var equal = (a, b) => a.length === b.length && a.every((byte, i) => byte === b[i]);
|
|
1276
|
+
function cbor(data, start) {
|
|
1277
|
+
let i = start;
|
|
1278
|
+
if (i >= data.length) throw new WebAuthnError("CBOR: truncated");
|
|
1279
|
+
const first = data[i];
|
|
1280
|
+
const major = first >> 5;
|
|
1281
|
+
const info = first & 31;
|
|
1282
|
+
i += 1;
|
|
1283
|
+
if (major === 7) {
|
|
1284
|
+
if (info === 20) return [false, i];
|
|
1285
|
+
if (info === 21) return [true, i];
|
|
1286
|
+
throw new WebAuthnError("CBOR: unsupported simple value or float");
|
|
1287
|
+
}
|
|
1288
|
+
let n;
|
|
1289
|
+
if (info < 24) {
|
|
1290
|
+
n = info;
|
|
1291
|
+
} else if (info >= 24 && info <= 27) {
|
|
1292
|
+
const size = 1 << info - 24;
|
|
1293
|
+
if (i + size > data.length) throw new WebAuthnError("CBOR: truncated");
|
|
1294
|
+
let big = 0n;
|
|
1295
|
+
for (let k = 0; k < size; k += 1) big = big << 8n | BigInt(data[i + k]);
|
|
1296
|
+
if (big > BigInt(Number.MAX_SAFE_INTEGER)) throw new WebAuthnError("CBOR: number too large");
|
|
1297
|
+
n = Number(big);
|
|
1298
|
+
i += size;
|
|
1299
|
+
} else {
|
|
1300
|
+
throw new WebAuthnError("CBOR: indefinite lengths are not supported");
|
|
1301
|
+
}
|
|
1302
|
+
if (major === 0) return [n, i];
|
|
1303
|
+
if (major === 1) return [-1 - n, i];
|
|
1304
|
+
if (major === 2 || major === 3) {
|
|
1305
|
+
if (i + n > data.length) throw new WebAuthnError("CBOR: truncated");
|
|
1306
|
+
const raw = data.slice(i, i + n);
|
|
1307
|
+
i += n;
|
|
1308
|
+
if (major === 2) return [raw, i];
|
|
1309
|
+
try {
|
|
1310
|
+
return [new TextDecoder("utf-8", { fatal: true }).decode(raw), i];
|
|
1311
|
+
} catch {
|
|
1312
|
+
throw new WebAuthnError("CBOR: text is not UTF-8");
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
if (major === 4) {
|
|
1316
|
+
const items = [];
|
|
1317
|
+
for (let k = 0; k < n; k += 1) {
|
|
1318
|
+
const [item, next] = cbor(data, i);
|
|
1319
|
+
items.push(item);
|
|
1320
|
+
i = next;
|
|
1321
|
+
}
|
|
1322
|
+
return [items, i];
|
|
1323
|
+
}
|
|
1324
|
+
if (major === 5) {
|
|
1325
|
+
const out = /* @__PURE__ */ new Map();
|
|
1326
|
+
for (let k = 0; k < n; k += 1) {
|
|
1327
|
+
const [key, afterKey] = cbor(data, i);
|
|
1328
|
+
if (typeof key !== "number" && typeof key !== "string") {
|
|
1329
|
+
throw new WebAuthnError("CBOR: map keys must be integers or text");
|
|
1330
|
+
}
|
|
1331
|
+
if (out.has(key)) throw new WebAuthnError("CBOR: duplicate map key");
|
|
1332
|
+
const [value, afterValue] = cbor(data, afterKey);
|
|
1333
|
+
out.set(key, value);
|
|
1334
|
+
i = afterValue;
|
|
1335
|
+
}
|
|
1336
|
+
return [out, i];
|
|
1337
|
+
}
|
|
1338
|
+
throw new WebAuthnError("CBOR: tags are not supported");
|
|
1339
|
+
}
|
|
1340
|
+
function cborWhole(data) {
|
|
1341
|
+
const [value, end] = cbor(data, 0);
|
|
1342
|
+
if (end !== data.length) throw new WebAuthnError("CBOR: trailing bytes");
|
|
1343
|
+
return value;
|
|
1344
|
+
}
|
|
1345
|
+
function checkClientData(raw, kind, challenge, origin) {
|
|
1346
|
+
let data;
|
|
1347
|
+
try {
|
|
1348
|
+
data = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw));
|
|
1349
|
+
} catch {
|
|
1350
|
+
throw new WebAuthnError("clientDataJSON is not JSON");
|
|
1351
|
+
}
|
|
1352
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
1353
|
+
throw new WebAuthnError("clientDataJSON is not an object");
|
|
1354
|
+
}
|
|
1355
|
+
const d = data;
|
|
1356
|
+
if (d["type"] !== kind) throw new WebAuthnError(`clientData.type must be ${kind}`);
|
|
1357
|
+
if (d["challenge"] !== Buffer.from(challenge).toString("base64url")) {
|
|
1358
|
+
throw new WebAuthnError("clientData.challenge doesn't match");
|
|
1359
|
+
}
|
|
1360
|
+
if (d["origin"] !== origin) throw new WebAuthnError("clientData.origin doesn't match");
|
|
1361
|
+
if ("crossOrigin" in d && d["crossOrigin"] !== false) {
|
|
1362
|
+
throw new WebAuthnError("cross-origin ceremonies are refused");
|
|
1363
|
+
}
|
|
1364
|
+
if ("topOrigin" in d) throw new WebAuthnError("ceremonies in a frame are refused");
|
|
1365
|
+
}
|
|
1366
|
+
function checkAuthData(data, rpId) {
|
|
1367
|
+
if (data.length < 37) throw new WebAuthnError("authenticatorData too short");
|
|
1368
|
+
if (!equal(data.slice(0, 32), sha256(new TextEncoder().encode(rpId)))) {
|
|
1369
|
+
throw new WebAuthnError("rpIdHash doesn't match");
|
|
1370
|
+
}
|
|
1371
|
+
const flags = data[32];
|
|
1372
|
+
if (!(flags & UP)) throw new WebAuthnError("user not present");
|
|
1373
|
+
if (!(flags & UV)) throw new WebAuthnError("user not verified");
|
|
1374
|
+
const count = new DataView(data.buffer, data.byteOffset + 33, 4).getUint32(0, false);
|
|
1375
|
+
return { flags, count };
|
|
1376
|
+
}
|
|
1377
|
+
function point(bytes) {
|
|
1378
|
+
try {
|
|
1379
|
+
return p256.ProjectivePoint.fromHex(bytes);
|
|
1380
|
+
} catch {
|
|
1381
|
+
throw new WebAuthnError("not a P-256 point");
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
function verifyRegistration(args) {
|
|
1385
|
+
checkClientData(args.clientDataJSON, "webauthn.create", args.challenge, args.origin);
|
|
1386
|
+
const att = cborWhole(args.attestationObject);
|
|
1387
|
+
if (!(att instanceof Map) || att.size !== 3 || !att.has("fmt") || !att.has("attStmt") || !att.has("authData")) {
|
|
1388
|
+
throw new WebAuthnError("attestationObject must have fmt, attStmt and authData");
|
|
1389
|
+
}
|
|
1390
|
+
const stmt = att.get("attStmt");
|
|
1391
|
+
if (att.get("fmt") !== "none" || !(stmt instanceof Map) || stmt.size !== 0) {
|
|
1392
|
+
throw new WebAuthnError("only attestation format 'none' is accepted");
|
|
1393
|
+
}
|
|
1394
|
+
const data = att.get("authData");
|
|
1395
|
+
if (!(data instanceof Uint8Array)) throw new WebAuthnError("authData must be bytes");
|
|
1396
|
+
const { flags, count } = checkAuthData(data, args.rpId);
|
|
1397
|
+
if (!(flags & AT)) throw new WebAuthnError("no attested credential data");
|
|
1398
|
+
if (data.length < 55) throw new WebAuthnError("attested credential data too short");
|
|
1399
|
+
const idLen = data[53] << 8 | data[54];
|
|
1400
|
+
const credentialId = data.slice(55, 55 + idLen);
|
|
1401
|
+
if (credentialId.length !== idLen) throw new WebAuthnError("credential id truncated");
|
|
1402
|
+
const [cose, end] = cbor(data, 55 + idLen);
|
|
1403
|
+
if (!(flags & ED) && end !== data.length) {
|
|
1404
|
+
throw new WebAuthnError("trailing bytes after the credential key");
|
|
1405
|
+
}
|
|
1406
|
+
if (!(cose instanceof Map) || cose.get(1) !== 2 || cose.get(3) !== ES256 || cose.get(-1) !== 1) {
|
|
1407
|
+
throw new WebAuthnError("only ES256 (P-256) passkeys are accepted");
|
|
1408
|
+
}
|
|
1409
|
+
const x = cose.get(-2);
|
|
1410
|
+
const y = cose.get(-3);
|
|
1411
|
+
if (!(x instanceof Uint8Array) || !(y instanceof Uint8Array) || x.length !== 32 || y.length !== 32) {
|
|
1412
|
+
throw new WebAuthnError("bad P-256 coordinates");
|
|
1413
|
+
}
|
|
1414
|
+
const key = point(new Uint8Array([4, ...x, ...y]));
|
|
1415
|
+
return {
|
|
1416
|
+
id: credentialId,
|
|
1417
|
+
alg: ES256,
|
|
1418
|
+
publicKey: key.toRawBytes(true),
|
|
1419
|
+
backupEligible: Boolean(flags & BE),
|
|
1420
|
+
signCount: count
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
function verifyAssertion(credential, args) {
|
|
1424
|
+
if (credential.alg !== ES256) throw new WebAuthnError("only ES256 (P-256) passkeys are accepted");
|
|
1425
|
+
checkClientData(args.clientDataJSON, "webauthn.get", args.challenge, args.origin);
|
|
1426
|
+
const { flags, count } = checkAuthData(args.authenticatorData, args.rpId);
|
|
1427
|
+
if (Boolean(flags & BE) !== credential.backupEligible) {
|
|
1428
|
+
throw new WebAuthnError("backup eligibility changed since registration");
|
|
1429
|
+
}
|
|
1430
|
+
const key = point(credential.publicKey);
|
|
1431
|
+
const digest = sha256(args.authenticatorData, sha256(args.clientDataJSON));
|
|
1432
|
+
let ok = false;
|
|
1433
|
+
try {
|
|
1434
|
+
ok = p256.verify(args.signature, digest, key.toRawBytes(false), {
|
|
1435
|
+
format: "der",
|
|
1436
|
+
lowS: false,
|
|
1437
|
+
prehash: false
|
|
1438
|
+
});
|
|
1439
|
+
} catch {
|
|
1440
|
+
ok = false;
|
|
1441
|
+
}
|
|
1442
|
+
if (!ok) throw new WebAuthnError("bad signature");
|
|
1443
|
+
const checkCounter = args.checkCounter ?? true;
|
|
1444
|
+
if (checkCounter && (count || credential.signCount) && count <= credential.signCount) {
|
|
1445
|
+
throw new WebAuthnError("sign count didn't go up: the passkey may have been cloned");
|
|
1446
|
+
}
|
|
1447
|
+
return count;
|
|
1448
|
+
}
|
|
1449
|
+
function approvalChallenge(items, screen) {
|
|
1450
|
+
if (items.length === 0) throw new WebAuthnError("an approval covers at least one object");
|
|
1451
|
+
const body = {
|
|
1452
|
+
set: items.map(([t, h]) => ({ t, h })),
|
|
1453
|
+
screen: createHash3("sha256").update(screen, "utf8").digest("hex")
|
|
1454
|
+
};
|
|
1455
|
+
let data;
|
|
1456
|
+
try {
|
|
1457
|
+
data = canonical(body);
|
|
1458
|
+
} catch (err) {
|
|
1459
|
+
if (err instanceof SignedObjectError) throw new WebAuthnError(`bad approval set: ${err.message}`);
|
|
1460
|
+
throw err;
|
|
1461
|
+
}
|
|
1462
|
+
return sha256(new TextEncoder().encode("UAM-APPROVAL-v1\n"), data);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// src/version.ts
|
|
1466
|
+
var SDK_VERSION = "0.4.0";
|
|
1467
|
+
|
|
664
1468
|
// src/sdk/config.ts
|
|
665
1469
|
import { homedir } from "os";
|
|
666
1470
|
import { join } from "path";
|
|
667
1471
|
var DEFAULT_RELAY_URL = "https://relay.youam.network";
|
|
1472
|
+
function trimRelayUrl(url) {
|
|
1473
|
+
return url.replace(/\/+$/, "");
|
|
1474
|
+
}
|
|
668
1475
|
var VALID_POLICIES = /* @__PURE__ */ new Set([
|
|
669
1476
|
"auto-accept",
|
|
670
1477
|
"approval-required",
|
|
@@ -681,9 +1488,13 @@ var SDKConfig = class {
|
|
|
681
1488
|
transportType;
|
|
682
1489
|
trustPolicy;
|
|
683
1490
|
relayDomain;
|
|
1491
|
+
/** The domain the caller chose, if any; null when only derived. */
|
|
1492
|
+
explicitRelayDomain;
|
|
684
1493
|
constructor(options) {
|
|
685
1494
|
this.name = options.name;
|
|
686
|
-
this.relayUrl =
|
|
1495
|
+
this.relayUrl = trimRelayUrl(
|
|
1496
|
+
options.relayUrl ?? process.env["UAM_RELAY_URL"] ?? DEFAULT_RELAY_URL
|
|
1497
|
+
);
|
|
687
1498
|
if (options.relayWsUrl) {
|
|
688
1499
|
this.relayWsUrl = options.relayWsUrl;
|
|
689
1500
|
} else {
|
|
@@ -694,6 +1505,7 @@ var SDKConfig = class {
|
|
|
694
1505
|
this.relayWsUrl = wsUrl;
|
|
695
1506
|
}
|
|
696
1507
|
const envDomain = process.env["UAM_RELAY_DOMAIN"];
|
|
1508
|
+
this.explicitRelayDomain = envDomain || options.relayDomain || null;
|
|
697
1509
|
if (envDomain) {
|
|
698
1510
|
this.relayDomain = envDomain;
|
|
699
1511
|
} else if (options.relayDomain) {
|
|
@@ -727,6 +1539,27 @@ var SDKConfig = class {
|
|
|
727
1539
|
}
|
|
728
1540
|
};
|
|
729
1541
|
|
|
1542
|
+
// src/sdk/freshness.ts
|
|
1543
|
+
var RELAY_META_FEATURE = "relay_meta";
|
|
1544
|
+
var STORED_MAIL_MAX_AGE = 7 * 86400;
|
|
1545
|
+
var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
1546
|
+
function relayVouches(raw, signedAt, now, relayDomain) {
|
|
1547
|
+
const meta = raw["relay_meta"];
|
|
1548
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
|
|
1549
|
+
return false;
|
|
1550
|
+
}
|
|
1551
|
+
const stamp = meta;
|
|
1552
|
+
if (stamp["v"] !== 1 || stamp["relay"] !== relayDomain) return false;
|
|
1553
|
+
if (stamp["stored"] !== true) return false;
|
|
1554
|
+
const received = stamp["received_at"];
|
|
1555
|
+
if (typeof received !== "string" || !ISO_TIME.test(received)) return false;
|
|
1556
|
+
const receivedAt = Date.parse(received);
|
|
1557
|
+
if (Number.isNaN(receivedAt)) return false;
|
|
1558
|
+
const window = MAX_ENVELOPE_AGE * 1e3;
|
|
1559
|
+
const age = now - signedAt;
|
|
1560
|
+
return Math.abs(receivedAt - signedAt) <= window && age >= 0 && age <= STORED_MAIL_MAX_AGE * 1e3 + window;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
730
1563
|
// src/sdk/key-manager.ts
|
|
731
1564
|
import {
|
|
732
1565
|
chmodSync,
|
|
@@ -803,17 +1636,54 @@ var KeyManager = class {
|
|
|
803
1636
|
writeFileSync(tokenPath, token);
|
|
804
1637
|
this._setPermissions(tokenPath);
|
|
805
1638
|
}
|
|
1639
|
+
/**
|
|
1640
|
+
* Store the address `relayUrl` gave this agent.
|
|
1641
|
+
*/
|
|
1642
|
+
saveAddress(name, address2, relayUrl) {
|
|
1643
|
+
mkdirSync(this._keyDir, { recursive: true });
|
|
1644
|
+
writeFileSync(
|
|
1645
|
+
join2(this._keyDir, `${name}.address`),
|
|
1646
|
+
JSON.stringify({ address: address2, relay_url: relayUrl })
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Load the address saved for `relayUrl`.
|
|
1651
|
+
*
|
|
1652
|
+
* Returns null if there is none: the file is missing, unreadable or
|
|
1653
|
+
* malformed, or it belongs to another relay.
|
|
1654
|
+
*/
|
|
1655
|
+
loadAddress(name, relayUrl) {
|
|
1656
|
+
try {
|
|
1657
|
+
const path = join2(this._keyDir, `${name}.address`);
|
|
1658
|
+
const saved = JSON.parse(readFileSync(path, "utf-8"));
|
|
1659
|
+
const address2 = saved?.address;
|
|
1660
|
+
const savedRelay = saved?.relay_url;
|
|
1661
|
+
if (typeof address2 !== "string" || typeof savedRelay !== "string") {
|
|
1662
|
+
return null;
|
|
1663
|
+
}
|
|
1664
|
+
if (trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl)) return null;
|
|
1665
|
+
parseAddress(address2);
|
|
1666
|
+
return address2;
|
|
1667
|
+
} catch {
|
|
1668
|
+
return null;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
806
1671
|
/**
|
|
807
1672
|
* Load a previously saved token, or return null.
|
|
808
1673
|
*
|
|
809
1674
|
* Checks UAM_TOKEN env var first, then file-based storage,
|
|
810
|
-
* then legacy .api_key files for backward compatibility.
|
|
1675
|
+
* then legacy .api_key files for backward compatibility. With `relayUrl`,
|
|
1676
|
+
* a saved token that belongs to another relay counts as none, so it is
|
|
1677
|
+
* only offered to the relay that issued it.
|
|
811
1678
|
*/
|
|
812
|
-
loadToken(name) {
|
|
1679
|
+
loadToken(name, relayUrl) {
|
|
813
1680
|
const envToken = process.env["UAM_TOKEN"];
|
|
814
1681
|
if (envToken) {
|
|
815
1682
|
return envToken.trim();
|
|
816
1683
|
}
|
|
1684
|
+
if (relayUrl !== void 0 && this._savedForAnotherRelay(name, relayUrl)) {
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
817
1687
|
const tokenPath = join2(this._keyDir, `${name}.token`);
|
|
818
1688
|
if (existsSync(tokenPath)) {
|
|
819
1689
|
return readFileSync(tokenPath, "utf-8").trim();
|
|
@@ -824,6 +1694,20 @@ var KeyManager = class {
|
|
|
824
1694
|
}
|
|
825
1695
|
return null;
|
|
826
1696
|
}
|
|
1697
|
+
/**
|
|
1698
|
+
* True if the saved address says this agent's files are another relay's.
|
|
1699
|
+
* A missing or damaged file says nothing.
|
|
1700
|
+
*/
|
|
1701
|
+
_savedForAnotherRelay(name, relayUrl) {
|
|
1702
|
+
try {
|
|
1703
|
+
const path = join2(this._keyDir, `${name}.address`);
|
|
1704
|
+
const saved = JSON.parse(readFileSync(path, "utf-8"));
|
|
1705
|
+
const savedRelay = saved?.relay_url;
|
|
1706
|
+
return typeof savedRelay === "string" && trimRelayUrl(savedRelay) !== trimRelayUrl(relayUrl);
|
|
1707
|
+
} catch {
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
827
1711
|
/** Set file permissions to 0o600 (owner read/write only). */
|
|
828
1712
|
_setPermissions(path) {
|
|
829
1713
|
if (platform() !== "win32") {
|
|
@@ -847,10 +1731,60 @@ var KeyManager = class {
|
|
|
847
1731
|
}
|
|
848
1732
|
};
|
|
849
1733
|
|
|
1734
|
+
// src/sdk/message.ts
|
|
1735
|
+
var SEND_STATUSES = [
|
|
1736
|
+
"delivered",
|
|
1737
|
+
"stored",
|
|
1738
|
+
"relayed",
|
|
1739
|
+
"queued",
|
|
1740
|
+
"duplicate",
|
|
1741
|
+
"dropped",
|
|
1742
|
+
"pending",
|
|
1743
|
+
"accepted",
|
|
1744
|
+
"unknown"
|
|
1745
|
+
];
|
|
1746
|
+
function sendStatus(answer) {
|
|
1747
|
+
if (answer === null) return "unknown";
|
|
1748
|
+
const status = answer["status"];
|
|
1749
|
+
if (typeof status === "string" && SEND_STATUSES.includes(status) && status !== "accepted" && status !== "unknown") {
|
|
1750
|
+
return status;
|
|
1751
|
+
}
|
|
1752
|
+
return "accepted";
|
|
1753
|
+
}
|
|
1754
|
+
function supports(features, name, version = 1) {
|
|
1755
|
+
const prefix = `${name}:`;
|
|
1756
|
+
for (const feature of features) {
|
|
1757
|
+
if (feature === name && version <= 1) return true;
|
|
1758
|
+
if (feature.startsWith(prefix)) {
|
|
1759
|
+
const rest = feature.slice(prefix.length);
|
|
1760
|
+
if (/^\d+$/.test(rest) && Number(rest) >= version) return true;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
return false;
|
|
1764
|
+
}
|
|
1765
|
+
var PENDING_NOTE = "Not delivered: {to} only accepts messages from approved contacts. Do not resend it.";
|
|
1766
|
+
function pendingNote(to) {
|
|
1767
|
+
return PENDING_NOTE.replace("{to}", to);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
850
1770
|
// src/sdk/contact-book.ts
|
|
851
1771
|
import { mkdirSync as mkdirSync2 } from "fs";
|
|
852
1772
|
import { join as join3, dirname } from "path";
|
|
853
1773
|
import Database from "better-sqlite3";
|
|
1774
|
+
|
|
1775
|
+
// src/sdk/log.ts
|
|
1776
|
+
var log = {
|
|
1777
|
+
warn(message) {
|
|
1778
|
+
console.warn(`[uam] ${message}`);
|
|
1779
|
+
},
|
|
1780
|
+
info(message) {
|
|
1781
|
+
if (process.env["UAM_DEBUG"]) {
|
|
1782
|
+
console.info(`[uam] ${message}`);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
};
|
|
1786
|
+
|
|
1787
|
+
// src/sdk/contact-book.ts
|
|
854
1788
|
var ContactBook = class {
|
|
855
1789
|
_dbPath;
|
|
856
1790
|
_db = null;
|
|
@@ -930,36 +1864,36 @@ var ContactBook = class {
|
|
|
930
1864
|
/**
|
|
931
1865
|
* Check if an address is in the contact book (in-memory, no I/O).
|
|
932
1866
|
*/
|
|
933
|
-
isKnown(
|
|
934
|
-
return this._knownAddresses.has(
|
|
1867
|
+
isKnown(address2) {
|
|
1868
|
+
return this._knownAddresses.has(address2);
|
|
935
1869
|
}
|
|
936
1870
|
/**
|
|
937
1871
|
* Check if an address matches any blocked pattern (O(1) set lookup).
|
|
938
1872
|
*/
|
|
939
|
-
isBlocked(
|
|
940
|
-
if (this._blockedExact.has(
|
|
1873
|
+
isBlocked(address2) {
|
|
1874
|
+
if (this._blockedExact.has(address2)) {
|
|
941
1875
|
return true;
|
|
942
1876
|
}
|
|
943
|
-
if (
|
|
944
|
-
const
|
|
945
|
-
return this._blockedDomains.has(
|
|
1877
|
+
if (address2.includes("::")) {
|
|
1878
|
+
const domain2 = address2.split("::")[1];
|
|
1879
|
+
return this._blockedDomains.has(domain2);
|
|
946
1880
|
}
|
|
947
1881
|
return false;
|
|
948
1882
|
}
|
|
949
1883
|
/**
|
|
950
1884
|
* Look up the public key for a known contact.
|
|
951
1885
|
*/
|
|
952
|
-
getPublicKey(
|
|
1886
|
+
getPublicKey(address2) {
|
|
953
1887
|
if (this._db === null) return null;
|
|
954
|
-
const row = this._db.prepare("SELECT public_key FROM contacts WHERE address = ?").get(
|
|
1888
|
+
const row = this._db.prepare("SELECT public_key FROM contacts WHERE address = ?").get(address2);
|
|
955
1889
|
return row?.public_key ?? null;
|
|
956
1890
|
}
|
|
957
1891
|
/**
|
|
958
1892
|
* Return the relay URLs for a known contact (CARD-04).
|
|
959
1893
|
*/
|
|
960
|
-
getRelayUrls(
|
|
1894
|
+
getRelayUrls(address2) {
|
|
961
1895
|
if (this._db === null) return null;
|
|
962
|
-
const row = this._db.prepare("SELECT relay, relays_json FROM contacts WHERE address = ?").get(
|
|
1896
|
+
const row = this._db.prepare("SELECT relay, relays_json FROM contacts WHERE address = ?").get(address2);
|
|
963
1897
|
if (!row) return null;
|
|
964
1898
|
if (row.relays_json !== null) {
|
|
965
1899
|
return JSON.parse(row.relays_json);
|
|
@@ -971,11 +1905,30 @@ var ContactBook = class {
|
|
|
971
1905
|
}
|
|
972
1906
|
/**
|
|
973
1907
|
* Add or update a contact (upsert).
|
|
1908
|
+
*
|
|
1909
|
+
* Refuses to replace the key of a contact whose trust_state is "pinned"
|
|
1910
|
+
* or "verified" with a different key, unless `force: true` is passed (for
|
|
1911
|
+
* a deliberate key rotation, which is logged). Matches the Python SDK's
|
|
1912
|
+
* ContactBook.add_contact.
|
|
1913
|
+
*
|
|
1914
|
+
* @throws {KeyPinningError} If a pinned/verified contact's key would be
|
|
1915
|
+
* replaced by a different key without `force`.
|
|
974
1916
|
*/
|
|
975
|
-
addContact(
|
|
1917
|
+
addContact(address2, publicKey, options) {
|
|
976
1918
|
if (this._db === null) {
|
|
977
1919
|
throw new Error("ContactBook not open. Call open() first.");
|
|
978
1920
|
}
|
|
1921
|
+
const existing = this._db.prepare("SELECT public_key, trust_state FROM contacts WHERE address = ?").get(address2);
|
|
1922
|
+
if (existing !== void 0 && (existing.trust_state === "pinned" || existing.trust_state === "verified") && existing.public_key !== publicKey) {
|
|
1923
|
+
if (!options?.force) {
|
|
1924
|
+
throw new KeyPinningError(
|
|
1925
|
+
`Refusing to overwrite ${existing.trust_state} contact ${JSON.stringify(address2)}: stored public key does not match the new key. Pass force: true to rotate.`
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1928
|
+
log.warn(
|
|
1929
|
+
`Force-replacing the key of ${existing.trust_state} contact ${address2} (key rotation)`
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
979
1932
|
const displayName = options?.displayName ?? null;
|
|
980
1933
|
const trustState = options?.trustState ?? "trusted";
|
|
981
1934
|
const trustSource = options?.trustSource ?? null;
|
|
@@ -992,8 +1945,8 @@ var ContactBook = class {
|
|
|
992
1945
|
relay = COALESCE(excluded.relay, contacts.relay),
|
|
993
1946
|
relays_json = COALESCE(excluded.relays_json, contacts.relays_json),
|
|
994
1947
|
last_seen = datetime('now')`
|
|
995
|
-
).run(
|
|
996
|
-
this._knownAddresses.add(
|
|
1948
|
+
).run(address2, publicKey, displayName, trustState, trustSource, relay, relaysJson);
|
|
1949
|
+
this._knownAddresses.add(address2);
|
|
997
1950
|
}
|
|
998
1951
|
/**
|
|
999
1952
|
* Return all contacts sorted by last_seen DESC.
|
|
@@ -1014,13 +1967,13 @@ var ContactBook = class {
|
|
|
1014
1967
|
/**
|
|
1015
1968
|
* Store a pending handshake request.
|
|
1016
1969
|
*/
|
|
1017
|
-
addPending(
|
|
1970
|
+
addPending(address2, contactCardJson) {
|
|
1018
1971
|
if (this._db === null) {
|
|
1019
1972
|
throw new Error("ContactBook not open. Call open() first.");
|
|
1020
1973
|
}
|
|
1021
1974
|
this._db.prepare(
|
|
1022
1975
|
`INSERT OR REPLACE INTO pending_handshakes (address, contact_card) VALUES (?, ?)`
|
|
1023
|
-
).run(
|
|
1976
|
+
).run(address2, contactCardJson);
|
|
1024
1977
|
}
|
|
1025
1978
|
/**
|
|
1026
1979
|
* Retrieve all pending handshake requests.
|
|
@@ -1039,9 +1992,9 @@ var ContactBook = class {
|
|
|
1039
1992
|
/**
|
|
1040
1993
|
* Remove a pending handshake request.
|
|
1041
1994
|
*/
|
|
1042
|
-
removePending(
|
|
1995
|
+
removePending(address2) {
|
|
1043
1996
|
if (this._db === null) return;
|
|
1044
|
-
this._db.prepare("DELETE FROM pending_handshakes WHERE address = ?").run(
|
|
1997
|
+
this._db.prepare("DELETE FROM pending_handshakes WHERE address = ?").run(address2);
|
|
1045
1998
|
}
|
|
1046
1999
|
/**
|
|
1047
2000
|
* Return pending handshakes older than N days.
|
|
@@ -1061,31 +2014,31 @@ var ContactBook = class {
|
|
|
1061
2014
|
/**
|
|
1062
2015
|
* Return the trust_state for an address, or null if unknown.
|
|
1063
2016
|
*/
|
|
1064
|
-
getTrustState(
|
|
2017
|
+
getTrustState(address2) {
|
|
1065
2018
|
if (this._db === null) return null;
|
|
1066
|
-
const row = this._db.prepare("SELECT trust_state FROM contacts WHERE address = ?").get(
|
|
2019
|
+
const row = this._db.prepare("SELECT trust_state FROM contacts WHERE address = ?").get(address2);
|
|
1067
2020
|
return row?.trust_state ?? null;
|
|
1068
2021
|
}
|
|
1069
2022
|
/**
|
|
1070
2023
|
* Set the pinned_at timestamp and trust_state='pinned' for a contact (TOFU).
|
|
1071
2024
|
*/
|
|
1072
|
-
setPinnedAt(
|
|
2025
|
+
setPinnedAt(address2) {
|
|
1073
2026
|
if (this._db === null) {
|
|
1074
2027
|
throw new Error("ContactBook not open. Call open() first.");
|
|
1075
2028
|
}
|
|
1076
2029
|
this._db.prepare(
|
|
1077
2030
|
"UPDATE contacts SET pinned_at = datetime('now'), trust_state = 'pinned' WHERE address = ?"
|
|
1078
|
-
).run(
|
|
2031
|
+
).run(address2);
|
|
1079
2032
|
}
|
|
1080
2033
|
/**
|
|
1081
2034
|
* Remove a contact by address. Returns true if a contact was deleted.
|
|
1082
2035
|
*/
|
|
1083
|
-
removeContact(
|
|
2036
|
+
removeContact(address2) {
|
|
1084
2037
|
if (this._db === null) {
|
|
1085
2038
|
throw new Error("ContactBook not open. Call open() first.");
|
|
1086
2039
|
}
|
|
1087
|
-
const result = this._db.prepare("DELETE FROM contacts WHERE address = ?").run(
|
|
1088
|
-
this._knownAddresses.delete(
|
|
2040
|
+
const result = this._db.prepare("DELETE FROM contacts WHERE address = ?").run(address2);
|
|
2041
|
+
this._knownAddresses.delete(address2);
|
|
1089
2042
|
return result.changes > 0;
|
|
1090
2043
|
}
|
|
1091
2044
|
/**
|
|
@@ -1093,8 +2046,8 @@ var ContactBook = class {
|
|
|
1093
2046
|
*
|
|
1094
2047
|
* Convenience helper for inbound message filtering (CARD-05, TOFU-02).
|
|
1095
2048
|
*/
|
|
1096
|
-
isTrustedOrVerified(
|
|
1097
|
-
const state = this.getTrustState(
|
|
2049
|
+
isTrustedOrVerified(address2) {
|
|
2050
|
+
const state = this.getTrustState(address2);
|
|
1098
2051
|
return state === "trusted" || state === "verified" || state === "pinned";
|
|
1099
2052
|
}
|
|
1100
2053
|
/**
|
|
@@ -1148,6 +2101,18 @@ var ContactBook = class {
|
|
|
1148
2101
|
};
|
|
1149
2102
|
|
|
1150
2103
|
// src/sdk/handshake.ts
|
|
2104
|
+
function bindCardToEnvelope(card, envelope, senderVk) {
|
|
2105
|
+
if (card.address !== envelope.fromAddress) {
|
|
2106
|
+
throw new InvalidEnvelopeError(
|
|
2107
|
+
`Contact card address ${JSON.stringify(card.address)} does not match envelope sender ${JSON.stringify(envelope.fromAddress)}`
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
2110
|
+
if (card.publicKey !== serializeVerifyKey(senderVk)) {
|
|
2111
|
+
throw new InvalidEnvelopeError(
|
|
2112
|
+
"Contact card public_key does not match envelope sender's verify key"
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
1151
2116
|
var HandshakeManager = class {
|
|
1152
2117
|
_contactBook;
|
|
1153
2118
|
_trustPolicy;
|
|
@@ -1190,7 +2155,7 @@ var HandshakeManager = class {
|
|
|
1190
2155
|
if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */) {
|
|
1191
2156
|
await this._handleRequest(agent, envelope, senderVk);
|
|
1192
2157
|
} else if (envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */) {
|
|
1193
|
-
await this._handleAccept(envelope, senderVk);
|
|
2158
|
+
await this._handleAccept(agent, envelope, senderVk);
|
|
1194
2159
|
} else if (envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
|
|
1195
2160
|
}
|
|
1196
2161
|
return null;
|
|
@@ -1208,6 +2173,7 @@ var HandshakeManager = class {
|
|
|
1208
2173
|
);
|
|
1209
2174
|
const card = contactCardFromDict(cardDict);
|
|
1210
2175
|
verifyContactCard(card);
|
|
2176
|
+
bindCardToEnvelope(card, envelope, senderVk);
|
|
1211
2177
|
if (this._trustPolicy === "auto-accept") {
|
|
1212
2178
|
this._contactBook.addContact(card.address, card.publicKey, {
|
|
1213
2179
|
displayName: card.displayName,
|
|
@@ -1226,8 +2192,46 @@ var HandshakeManager = class {
|
|
|
1226
2192
|
}
|
|
1227
2193
|
/**
|
|
1228
2194
|
* Process a handshake.accept: store the sender as pinned (TOFU).
|
|
2195
|
+
*
|
|
2196
|
+
* Only honoured when:
|
|
2197
|
+
* 1. we previously sent this address a handshake.request
|
|
2198
|
+
* (trust_state === "handshake-sent"); anything else is an unsolicited
|
|
2199
|
+
* accept and is dropped. This also means an accept can never
|
|
2200
|
+
* downgrade a trusted/verified contact to pinned;
|
|
2201
|
+
* 2. the payload decrypts with our key and the sender's key, and parses
|
|
2202
|
+
* as JSON. Otherwise it is dropped without pinning;
|
|
2203
|
+
* 3. an embedded contact_card, if present, is well-formed, correctly
|
|
2204
|
+
* self-signed, and bound to the envelope sender (throws otherwise).
|
|
1229
2205
|
*/
|
|
1230
|
-
async _handleAccept(envelope, senderVk) {
|
|
2206
|
+
async _handleAccept(agent, envelope, senderVk) {
|
|
2207
|
+
const prior = this._contactBook.getTrustState(envelope.fromAddress);
|
|
2208
|
+
if (prior !== "handshake-sent") {
|
|
2209
|
+
log.warn(
|
|
2210
|
+
`Ignoring unsolicited handshake.accept from ${envelope.fromAddress} (trust_state=${JSON.stringify(prior)})`
|
|
2211
|
+
);
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
let payload;
|
|
2215
|
+
try {
|
|
2216
|
+
const plaintext = decryptPayload(
|
|
2217
|
+
envelope.payload,
|
|
2218
|
+
agent._keyManager.signingKey,
|
|
2219
|
+
senderVk
|
|
2220
|
+
);
|
|
2221
|
+
payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
2222
|
+
} catch {
|
|
2223
|
+
log.warn(
|
|
2224
|
+
`Dropping handshake.accept ${envelope.messageId} from ${envelope.fromAddress}: payload could not be decrypted or parsed`
|
|
2225
|
+
);
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
if (payload !== null && typeof payload === "object" && "contact_card" in payload) {
|
|
2229
|
+
const card = contactCardFromDict(
|
|
2230
|
+
payload["contact_card"]
|
|
2231
|
+
);
|
|
2232
|
+
verifyContactCard(card);
|
|
2233
|
+
bindCardToEnvelope(card, envelope, senderVk);
|
|
2234
|
+
}
|
|
1231
2235
|
const senderPkStr = serializeVerifyKey(senderVk);
|
|
1232
2236
|
this._contactBook.addContact(envelope.fromAddress, senderPkStr, {
|
|
1233
2237
|
trustState: "pinned"
|
|
@@ -1284,6 +2288,10 @@ var HandshakeManager = class {
|
|
|
1284
2288
|
}
|
|
1285
2289
|
};
|
|
1286
2290
|
|
|
2291
|
+
// src/sdk/resolver-base.ts
|
|
2292
|
+
var AddressResolver = class {
|
|
2293
|
+
};
|
|
2294
|
+
|
|
1287
2295
|
// src/sdk/tier3.ts
|
|
1288
2296
|
import { createPublicClient, http, defineChain } from "viem";
|
|
1289
2297
|
var DEFAULT_RPC_URL = "https://sepolia.base.org";
|
|
@@ -1336,8 +2344,8 @@ var Tier3Resolver = class extends AddressResolver {
|
|
|
1336
2344
|
transport: http(rpcUrl)
|
|
1337
2345
|
});
|
|
1338
2346
|
}
|
|
1339
|
-
async resolvePublicKey(
|
|
1340
|
-
const parsed = parseAddress(
|
|
2347
|
+
async resolvePublicKey(address2, _token, _relayUrl) {
|
|
2348
|
+
const parsed = parseAddress(address2);
|
|
1341
2349
|
const name = parsed.domain;
|
|
1342
2350
|
const cached = this._cache.get(name);
|
|
1343
2351
|
if (cached && cached.expiresAt > Date.now()) {
|
|
@@ -1400,15 +2408,13 @@ var Tier3Resolver = class extends AddressResolver {
|
|
|
1400
2408
|
|
|
1401
2409
|
// src/sdk/resolver.ts
|
|
1402
2410
|
init_dns_verifier();
|
|
1403
|
-
var AddressResolver = class {
|
|
1404
|
-
};
|
|
1405
2411
|
var Tier1Resolver = class extends AddressResolver {
|
|
1406
|
-
async resolvePublicKey(
|
|
2412
|
+
async resolvePublicKey(address2, _token, relayUrl) {
|
|
1407
2413
|
const resp = await fetch(
|
|
1408
|
-
`${relayUrl}/api/v1/agents/${
|
|
2414
|
+
`${relayUrl}/api/v1/agents/${address2}/public-key`
|
|
1409
2415
|
);
|
|
1410
2416
|
if (resp.status === 404) {
|
|
1411
|
-
throw new UAMError(`Agent not found: ${
|
|
2417
|
+
throw new UAMError(`Agent not found: ${address2}`);
|
|
1412
2418
|
}
|
|
1413
2419
|
if (!resp.ok) {
|
|
1414
2420
|
throw new UAMError(
|
|
@@ -1420,8 +2426,8 @@ var Tier1Resolver = class extends AddressResolver {
|
|
|
1420
2426
|
}
|
|
1421
2427
|
};
|
|
1422
2428
|
var Tier2Resolver = class extends AddressResolver {
|
|
1423
|
-
async resolvePublicKey(
|
|
1424
|
-
const parsed = parseAddress(
|
|
2429
|
+
async resolvePublicKey(address2, _token, _relayUrl) {
|
|
2430
|
+
const parsed = parseAddress(address2);
|
|
1425
2431
|
const txtRecords = await queryUamTxt(parsed.domain);
|
|
1426
2432
|
for (const txt of txtRecords) {
|
|
1427
2433
|
const tags = parseUamTxt(txt);
|
|
@@ -1433,37 +2439,37 @@ var Tier2Resolver = class extends AddressResolver {
|
|
|
1433
2439
|
const httpsKey = await resolveKeyViaHttps(parsed.agent, parsed.domain);
|
|
1434
2440
|
if (httpsKey) return httpsKey;
|
|
1435
2441
|
throw new UAMError(
|
|
1436
|
-
`Could not resolve public key for ${
|
|
2442
|
+
`Could not resolve public key for ${address2} via DNS or HTTPS`
|
|
1437
2443
|
);
|
|
1438
2444
|
}
|
|
1439
2445
|
};
|
|
1440
2446
|
var SmartResolver = class extends AddressResolver {
|
|
1441
|
-
|
|
2447
|
+
relayDomain;
|
|
1442
2448
|
_tier1;
|
|
1443
2449
|
_tier2;
|
|
1444
2450
|
_tier3;
|
|
1445
2451
|
constructor(relayDomain, tier3Config) {
|
|
1446
2452
|
super();
|
|
1447
|
-
this.
|
|
2453
|
+
this.relayDomain = relayDomain;
|
|
1448
2454
|
this._tier1 = new Tier1Resolver();
|
|
1449
2455
|
this._tier2 = new Tier2Resolver();
|
|
1450
2456
|
this._tier3 = tier3Config ? new Tier3Resolver(tier3Config) : null;
|
|
1451
2457
|
}
|
|
1452
|
-
async resolvePublicKey(
|
|
1453
|
-
const parsed = parseAddress(
|
|
1454
|
-
const
|
|
1455
|
-
if (
|
|
1456
|
-
return this._tier1.resolvePublicKey(
|
|
2458
|
+
async resolvePublicKey(address2, token, relayUrl) {
|
|
2459
|
+
const parsed = parseAddress(address2);
|
|
2460
|
+
const domain2 = parsed.domain;
|
|
2461
|
+
if (domain2 === this.relayDomain) {
|
|
2462
|
+
return this._tier1.resolvePublicKey(address2, token, relayUrl);
|
|
1457
2463
|
}
|
|
1458
|
-
if (
|
|
1459
|
-
return this._tier2.resolvePublicKey(
|
|
2464
|
+
if (domain2.includes(".")) {
|
|
2465
|
+
return this._tier2.resolvePublicKey(address2, token, relayUrl);
|
|
1460
2466
|
}
|
|
1461
2467
|
if (!this._tier3) {
|
|
1462
2468
|
throw new UAMError(
|
|
1463
|
-
`Tier 3 resolution requires contract config. Cannot resolve dot-free domain: '${
|
|
2469
|
+
`Tier 3 resolution requires contract config. Cannot resolve dot-free domain: '${domain2}'`
|
|
1464
2470
|
);
|
|
1465
2471
|
}
|
|
1466
|
-
return this._tier3.resolvePublicKey(
|
|
2472
|
+
return this._tier3.resolvePublicKey(address2, token, relayUrl);
|
|
1467
2473
|
}
|
|
1468
2474
|
};
|
|
1469
2475
|
|
|
@@ -1472,15 +2478,23 @@ var TransportBase = class {
|
|
|
1472
2478
|
};
|
|
1473
2479
|
|
|
1474
2480
|
// src/sdk/transport/http.ts
|
|
2481
|
+
async function readAnswer(resp) {
|
|
2482
|
+
try {
|
|
2483
|
+
const body = await resp.json();
|
|
2484
|
+
return body !== null && typeof body === "object" && !Array.isArray(body) ? body : {};
|
|
2485
|
+
} catch {
|
|
2486
|
+
return {};
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
1475
2489
|
var HTTPTransport = class extends TransportBase {
|
|
1476
2490
|
_relayUrl;
|
|
1477
2491
|
_token;
|
|
1478
2492
|
_address;
|
|
1479
|
-
constructor(relayUrl, token,
|
|
2493
|
+
constructor(relayUrl, token, address2) {
|
|
1480
2494
|
super();
|
|
1481
2495
|
this._relayUrl = relayUrl.replace(/\/+$/, "");
|
|
1482
2496
|
this._token = token;
|
|
1483
|
-
this._address =
|
|
2497
|
+
this._address = address2;
|
|
1484
2498
|
}
|
|
1485
2499
|
async connect() {
|
|
1486
2500
|
}
|
|
@@ -1500,6 +2514,7 @@ var HTTPTransport = class extends TransportBase {
|
|
|
1500
2514
|
`HTTP send failed: ${resp.status} ${resp.statusText}`
|
|
1501
2515
|
);
|
|
1502
2516
|
}
|
|
2517
|
+
return readAnswer(resp);
|
|
1503
2518
|
}
|
|
1504
2519
|
async receive(limit = 50) {
|
|
1505
2520
|
const url = new URL(
|
|
@@ -1531,8 +2546,11 @@ import WebSocket from "ws";
|
|
|
1531
2546
|
var BASE_DELAY = 1;
|
|
1532
2547
|
var MAX_DELAY = 60;
|
|
1533
2548
|
var JITTER_RANGE = 1;
|
|
2549
|
+
var SEND_SETTLE_SECONDS = 5;
|
|
2550
|
+
var ACK_TIMEOUT_SECONDS = 5;
|
|
1534
2551
|
var WebSocketTransport = class extends TransportBase {
|
|
1535
2552
|
_url;
|
|
2553
|
+
_token;
|
|
1536
2554
|
_onMessage;
|
|
1537
2555
|
_ws = null;
|
|
1538
2556
|
_pending = [];
|
|
@@ -1541,9 +2559,20 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1541
2559
|
_connectReject = null;
|
|
1542
2560
|
_running = false;
|
|
1543
2561
|
_reconnectTimeout = null;
|
|
2562
|
+
/** Envelopes sent that the relay hasn't answered yet. */
|
|
2563
|
+
_unanswered = 0;
|
|
2564
|
+
_settling = null;
|
|
2565
|
+
_settled = null;
|
|
2566
|
+
/** Sends waiting for the relay's answer, by message_id. */
|
|
2567
|
+
_waiting = /* @__PURE__ */ new Map();
|
|
2568
|
+
/** @internal How long a close waits, in ms. Replaceable in tests. */
|
|
2569
|
+
_settleMs = SEND_SETTLE_SECONDS * 1e3;
|
|
2570
|
+
/** @internal How long send() waits for an answer, in ms. Replaceable in tests. */
|
|
2571
|
+
_ackMs = ACK_TIMEOUT_SECONDS * 1e3;
|
|
1544
2572
|
constructor(wsUrl, token, onMessage) {
|
|
1545
2573
|
super();
|
|
1546
|
-
this._url =
|
|
2574
|
+
this._url = wsUrl;
|
|
2575
|
+
this._token = token;
|
|
1547
2576
|
this._onMessage = onMessage ?? null;
|
|
1548
2577
|
}
|
|
1549
2578
|
async connect() {
|
|
@@ -1564,7 +2593,14 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1564
2593
|
};
|
|
1565
2594
|
});
|
|
1566
2595
|
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Close the connection, once the relay has answered what was sent.
|
|
2598
|
+
*
|
|
2599
|
+
* The relay stops reading a socket when it can't write an ack to it, so
|
|
2600
|
+
* closing straight after send() loses every frame but the first.
|
|
2601
|
+
*/
|
|
1567
2602
|
async disconnect() {
|
|
2603
|
+
await this._allAnswered();
|
|
1568
2604
|
this._running = false;
|
|
1569
2605
|
if (this._reconnectTimeout !== null) {
|
|
1570
2606
|
clearTimeout(this._reconnectTimeout);
|
|
@@ -1576,12 +2612,42 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1576
2612
|
this._ws = null;
|
|
1577
2613
|
}
|
|
1578
2614
|
this._connected = false;
|
|
2615
|
+
this._abandonWaiting();
|
|
1579
2616
|
}
|
|
2617
|
+
/**
|
|
2618
|
+
* Send an envelope and wait for the relay's answer.
|
|
2619
|
+
*
|
|
2620
|
+
* Resolves with the ack that names the message, or null when none came
|
|
2621
|
+
* within _ackMs or the socket went: the message may still have arrived.
|
|
2622
|
+
* Rejects with SendRefusedError when the relay refuses it by message_id
|
|
2623
|
+
* (relays before 1.1 don't name it, so their refusals end as null).
|
|
2624
|
+
*/
|
|
1580
2625
|
async send(envelope) {
|
|
1581
2626
|
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
|
1582
2627
|
throw new Error("WebSocket not connected");
|
|
1583
2628
|
}
|
|
2629
|
+
const messageId = envelope["message_id"];
|
|
2630
|
+
let answer = null;
|
|
2631
|
+
let timer;
|
|
2632
|
+
if (typeof messageId === "string" && !this._waiting.has(messageId)) {
|
|
2633
|
+
answer = new Promise((resolve, reject) => {
|
|
2634
|
+
this._waiting.set(messageId, { resolve, reject });
|
|
2635
|
+
timer = setTimeout(() => {
|
|
2636
|
+
log.warn(
|
|
2637
|
+
`No answer from the relay for ${messageId} within ${this._ackMs / 1e3}s`
|
|
2638
|
+
);
|
|
2639
|
+
this._settle(messageId, null);
|
|
2640
|
+
}, this._ackMs);
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
1584
2643
|
this._ws.send(JSON.stringify(envelope));
|
|
2644
|
+
this._unanswered += 1;
|
|
2645
|
+
if (answer === null) return null;
|
|
2646
|
+
try {
|
|
2647
|
+
return await answer;
|
|
2648
|
+
} finally {
|
|
2649
|
+
clearTimeout(timer);
|
|
2650
|
+
}
|
|
1585
2651
|
}
|
|
1586
2652
|
async receive(limit = 50) {
|
|
1587
2653
|
const result = this._pending.slice(0, limit);
|
|
@@ -1591,11 +2657,47 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1591
2657
|
async listen(callback) {
|
|
1592
2658
|
this._onMessage = callback;
|
|
1593
2659
|
}
|
|
2660
|
+
/** Resolves when nothing is unanswered, or after _settleMs regardless. */
|
|
2661
|
+
_allAnswered() {
|
|
2662
|
+
if (this._unanswered === 0) return Promise.resolve();
|
|
2663
|
+
this._settling ??= new Promise((resolve) => {
|
|
2664
|
+
const timeout = setTimeout(() => {
|
|
2665
|
+
log.warn(`Closing with ${this._unanswered} sends unanswered`);
|
|
2666
|
+
this._settled?.();
|
|
2667
|
+
}, this._settleMs);
|
|
2668
|
+
this._settled = () => {
|
|
2669
|
+
clearTimeout(timeout);
|
|
2670
|
+
this._settling = null;
|
|
2671
|
+
this._settled = null;
|
|
2672
|
+
resolve();
|
|
2673
|
+
};
|
|
2674
|
+
});
|
|
2675
|
+
return this._settling;
|
|
2676
|
+
}
|
|
2677
|
+
/** Hand the relay's answer (or a refusal) to the send waiting for it. */
|
|
2678
|
+
_settle(messageId, answer, error) {
|
|
2679
|
+
if (typeof messageId !== "string") return;
|
|
2680
|
+
const waiting = this._waiting.get(messageId);
|
|
2681
|
+
if (!waiting) return;
|
|
2682
|
+
this._waiting.delete(messageId);
|
|
2683
|
+
if (error) waiting.reject(error);
|
|
2684
|
+
else waiting.resolve(answer);
|
|
2685
|
+
}
|
|
2686
|
+
/** The socket went: no answers will come for what it carried. */
|
|
2687
|
+
_abandonWaiting() {
|
|
2688
|
+
const waiting = [...this._waiting.values()];
|
|
2689
|
+
this._waiting.clear();
|
|
2690
|
+
for (const w of waiting) w.resolve(null);
|
|
2691
|
+
}
|
|
2692
|
+
_answered() {
|
|
2693
|
+
this._unanswered = Math.max(0, this._unanswered - 1);
|
|
2694
|
+
if (this._unanswered === 0) this._settled?.();
|
|
2695
|
+
}
|
|
1594
2696
|
_startConnectionLoop() {
|
|
1595
2697
|
let attempt = 0;
|
|
1596
2698
|
const tryConnect = () => {
|
|
1597
2699
|
if (!this._running) return;
|
|
1598
|
-
const ws = new WebSocket(this._url);
|
|
2700
|
+
const ws = new WebSocket(this._url, [`bearer.${this._token}`]);
|
|
1599
2701
|
this._ws = ws;
|
|
1600
2702
|
ws.on("open", () => {
|
|
1601
2703
|
this._connected = true;
|
|
@@ -1616,6 +2718,9 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1616
2718
|
ws.on("close", () => {
|
|
1617
2719
|
this._connected = false;
|
|
1618
2720
|
this._ws = null;
|
|
2721
|
+
this._unanswered = 0;
|
|
2722
|
+
this._settled?.();
|
|
2723
|
+
this._abandonWaiting();
|
|
1619
2724
|
if (!this._running) return;
|
|
1620
2725
|
attempt += 1;
|
|
1621
2726
|
const delay = Math.min(BASE_DELAY * Math.pow(2, attempt), MAX_DELAY);
|
|
@@ -1632,33 +2737,279 @@ var WebSocketTransport = class extends TransportBase {
|
|
|
1632
2737
|
}
|
|
1633
2738
|
_handleMessage(msg) {
|
|
1634
2739
|
const msgType = msg["type"];
|
|
1635
|
-
if (
|
|
1636
|
-
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
1637
|
-
this._ws.send(JSON.stringify({ type: "pong" }));
|
|
1638
|
-
}
|
|
1639
|
-
} else if (msgType === "ack") {
|
|
1640
|
-
} else if (msgType === "error" || "error" in msg) {
|
|
1641
|
-
} else if ("uam_version" in msg) {
|
|
2740
|
+
if ("uam_version" in msg) {
|
|
1642
2741
|
if (this._onMessage) {
|
|
1643
2742
|
this._onMessage(msg).catch(() => {
|
|
1644
2743
|
});
|
|
1645
2744
|
} else {
|
|
1646
2745
|
this._pending.push(msg);
|
|
1647
2746
|
}
|
|
2747
|
+
} else if (msgType === "ping") {
|
|
2748
|
+
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
|
2749
|
+
this._ws.send(JSON.stringify({ type: "pong" }));
|
|
2750
|
+
}
|
|
2751
|
+
} else if (msgType === "ack") {
|
|
2752
|
+
this._answered();
|
|
2753
|
+
this._settle(msg["message_id"], msg);
|
|
2754
|
+
} else if (msgType === "relay.notice") {
|
|
2755
|
+
log.warn(
|
|
2756
|
+
`Relay notice: message ${String(msg["message_id"])} is ${String(msg["status"])}`
|
|
2757
|
+
);
|
|
2758
|
+
} else if (msgType === "error" || "error" in msg) {
|
|
2759
|
+
this._answered();
|
|
2760
|
+
const code = String(msg["error"] ?? msg["code"] ?? "error");
|
|
2761
|
+
const detail = typeof msg["detail"] === "string" ? msg["detail"] : void 0;
|
|
2762
|
+
log.warn(`Relay error: [${code}] ${detail ?? ""}`.trimEnd());
|
|
2763
|
+
const messageId = msg["message_id"];
|
|
2764
|
+
if (typeof messageId === "string") {
|
|
2765
|
+
this._settle(messageId, null, new SendRefusedError(messageId, code, detail));
|
|
2766
|
+
}
|
|
1648
2767
|
}
|
|
1649
2768
|
}
|
|
1650
2769
|
};
|
|
1651
2770
|
|
|
1652
2771
|
// src/sdk/transport/index.ts
|
|
1653
|
-
function createTransport(config, token,
|
|
2772
|
+
function createTransport(config, token, address2, onMessage) {
|
|
1654
2773
|
if (config.transportType === "http") {
|
|
1655
|
-
return new HTTPTransport(config.relayUrl, token,
|
|
2774
|
+
return new HTTPTransport(config.relayUrl, token, address2);
|
|
1656
2775
|
} else {
|
|
1657
2776
|
return new WebSocketTransport(config.relayWsUrl, token, onMessage);
|
|
1658
2777
|
}
|
|
1659
2778
|
}
|
|
1660
2779
|
|
|
2780
|
+
// src/sdk/replay-cache.ts
|
|
2781
|
+
import { mkdirSync as mkdirSync3 } from "fs";
|
|
2782
|
+
import { dirname as dirname2 } from "path";
|
|
2783
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
2784
|
+
import Database2 from "better-sqlite3";
|
|
2785
|
+
var RETENTION_SECONDS = STORED_MAIL_MAX_AGE + 2 * MAX_ENVELOPE_AGE;
|
|
2786
|
+
var PURGE_INTERVAL_MS = 6e4;
|
|
2787
|
+
var WAL_ATTEMPTS = 100;
|
|
2788
|
+
var WAL_RETRY_MS = 100;
|
|
2789
|
+
var SCHEMA = `
|
|
2790
|
+
CREATE TABLE IF NOT EXISTS seen_envelopes (
|
|
2791
|
+
from_address TEXT NOT NULL,
|
|
2792
|
+
message_id TEXT NOT NULL,
|
|
2793
|
+
seen_at REAL NOT NULL,
|
|
2794
|
+
PRIMARY KEY (from_address, message_id)
|
|
2795
|
+
) WITHOUT ROWID;
|
|
2796
|
+
CREATE INDEX IF NOT EXISTS seen_envelopes_seen_at ON seen_envelopes (seen_at);
|
|
2797
|
+
CREATE TABLE IF NOT EXISTS replay_meta (
|
|
2798
|
+
key TEXT PRIMARY KEY,
|
|
2799
|
+
value TEXT NOT NULL
|
|
2800
|
+
);
|
|
2801
|
+
`;
|
|
2802
|
+
function isLocked(err) {
|
|
2803
|
+
const code = err.code;
|
|
2804
|
+
return typeof code === "string" && (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED"));
|
|
2805
|
+
}
|
|
2806
|
+
async function enableWal(db) {
|
|
2807
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
2808
|
+
try {
|
|
2809
|
+
db.pragma("journal_mode = WAL");
|
|
2810
|
+
return;
|
|
2811
|
+
} catch (err) {
|
|
2812
|
+
if (attempt >= WAL_ATTEMPTS || !isLocked(err)) throw err;
|
|
2813
|
+
await sleep(WAL_RETRY_MS);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
var DEFAULT_REPLAY_CACHE_CAPACITY = 1e6;
|
|
2818
|
+
var EnvelopeReplayCache = class {
|
|
2819
|
+
capacity;
|
|
2820
|
+
ttlMs;
|
|
2821
|
+
_now;
|
|
2822
|
+
// Map iteration order is insertion order: first key = least recently used.
|
|
2823
|
+
_seen = /* @__PURE__ */ new Map();
|
|
2824
|
+
constructor(options) {
|
|
2825
|
+
const capacity = options?.capacity ?? 1e5;
|
|
2826
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
2827
|
+
throw new RangeError(
|
|
2828
|
+
`Replay cache capacity must be a positive integer, got ${capacity}`
|
|
2829
|
+
);
|
|
2830
|
+
}
|
|
2831
|
+
this.capacity = capacity;
|
|
2832
|
+
this.ttlMs = options?.ttlMs ?? 3e5;
|
|
2833
|
+
this._now = options?.now ?? (() => Date.now());
|
|
2834
|
+
}
|
|
2835
|
+
get size() {
|
|
2836
|
+
return this._seen.size;
|
|
2837
|
+
}
|
|
2838
|
+
/** True if this pair was seen within the TTL. Records nothing. */
|
|
2839
|
+
has(fromAddress, messageId) {
|
|
2840
|
+
const seenAt = this._seen.get(this._key(fromAddress, messageId));
|
|
2841
|
+
return seenAt !== void 0 && this._now() - seenAt < this.ttlMs;
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* Return true if this (fromAddress, messageId) pair was already seen
|
|
2845
|
+
* within the TTL (a replay). Otherwise record it and return false.
|
|
2846
|
+
*/
|
|
2847
|
+
seenOrRecord(fromAddress, messageId) {
|
|
2848
|
+
const key = this._key(fromAddress, messageId);
|
|
2849
|
+
const now = this._now();
|
|
2850
|
+
const seenAt = this._seen.get(key);
|
|
2851
|
+
if (seenAt !== void 0 && now - seenAt < this.ttlMs) {
|
|
2852
|
+
this._seen.delete(key);
|
|
2853
|
+
this._seen.set(key, seenAt);
|
|
2854
|
+
return true;
|
|
2855
|
+
}
|
|
2856
|
+
this._seen.delete(key);
|
|
2857
|
+
this._seen.set(key, now);
|
|
2858
|
+
while (this._seen.size > this.capacity) {
|
|
2859
|
+
const oldest = this._seen.keys().next().value;
|
|
2860
|
+
this._seen.delete(oldest);
|
|
2861
|
+
}
|
|
2862
|
+
return false;
|
|
2863
|
+
}
|
|
2864
|
+
_key(fromAddress, messageId) {
|
|
2865
|
+
return JSON.stringify([fromAddress, messageId]);
|
|
2866
|
+
}
|
|
2867
|
+
};
|
|
2868
|
+
var PersistentReplayCache = class {
|
|
2869
|
+
capacity;
|
|
2870
|
+
_path;
|
|
2871
|
+
_now;
|
|
2872
|
+
_db = null;
|
|
2873
|
+
_opening = null;
|
|
2874
|
+
_count = 0;
|
|
2875
|
+
_lastPurge = 0;
|
|
2876
|
+
_createdAt = 0;
|
|
2877
|
+
// Used only while the file is full, for envelopes inside MAX_ENVELOPE_AGE.
|
|
2878
|
+
// One can be signed that far ahead of us and stay acceptable until that far
|
|
2879
|
+
// behind, so its id must last both.
|
|
2880
|
+
_overflow;
|
|
2881
|
+
/**
|
|
2882
|
+
* @param path - The SQLite file
|
|
2883
|
+
* @param options.now - The clock, in ms since the epoch
|
|
2884
|
+
*/
|
|
2885
|
+
constructor(path, options) {
|
|
2886
|
+
const capacity = options?.capacity ?? DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
2887
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
2888
|
+
throw new RangeError(
|
|
2889
|
+
`Replay cache capacity must be a positive integer, got ${capacity}`
|
|
2890
|
+
);
|
|
2891
|
+
}
|
|
2892
|
+
this.capacity = capacity;
|
|
2893
|
+
this._path = path;
|
|
2894
|
+
this._now = options?.now ?? (() => Date.now());
|
|
2895
|
+
this._overflow = new EnvelopeReplayCache({
|
|
2896
|
+
capacity: 1e4,
|
|
2897
|
+
ttlMs: 2 * MAX_ENVELOPE_AGE * 1e3,
|
|
2898
|
+
now: this._now
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2901
|
+
/** Open the file now, rather than on first use. */
|
|
2902
|
+
async open() {
|
|
2903
|
+
await this._open();
|
|
2904
|
+
}
|
|
2905
|
+
/**
|
|
2906
|
+
* Return true if the envelope must be dropped as a replay. Otherwise record
|
|
2907
|
+
* the pair and return false.
|
|
2908
|
+
*
|
|
2909
|
+
* @param signedAt - The envelope's signed time, in ms since the epoch. It
|
|
2910
|
+
* matters only when the cache is full.
|
|
2911
|
+
*/
|
|
2912
|
+
async seenOrRecord(fromAddress, messageId, signedAt) {
|
|
2913
|
+
const db = await this._open();
|
|
2914
|
+
const now = this._now();
|
|
2915
|
+
if (now - this._lastPurge >= PURGE_INTERVAL_MS) this._purge(db);
|
|
2916
|
+
if (this._overflow.has(fromAddress, messageId)) return true;
|
|
2917
|
+
if (this._count < this.capacity) {
|
|
2918
|
+
const { changes } = db.prepare(
|
|
2919
|
+
"INSERT OR IGNORE INTO seen_envelopes (from_address, message_id, seen_at) VALUES (?, ?, ?)"
|
|
2920
|
+
).run(fromAddress, messageId, now / 1e3);
|
|
2921
|
+
if (changes === 0) return true;
|
|
2922
|
+
this._count += 1;
|
|
2923
|
+
return false;
|
|
2924
|
+
}
|
|
2925
|
+
const known = db.prepare(
|
|
2926
|
+
"SELECT 1 FROM seen_envelopes WHERE from_address = ? AND message_id = ?"
|
|
2927
|
+
).get(fromAddress, messageId);
|
|
2928
|
+
if (known !== void 0) return true;
|
|
2929
|
+
if (Math.abs(now - signedAt) <= MAX_ENVELOPE_AGE * 1e3) {
|
|
2930
|
+
return this._overflow.seenOrRecord(fromAddress, messageId);
|
|
2931
|
+
}
|
|
2932
|
+
log.warn(
|
|
2933
|
+
`Replay cache full (${this._count} ids); refused envelope ${messageId} from ${fromAddress} signed ${Math.round((now - signedAt) / 1e3)}s ago`
|
|
2934
|
+
);
|
|
2935
|
+
return true;
|
|
2936
|
+
}
|
|
2937
|
+
/** When this cache file was first created, in ms since the epoch. */
|
|
2938
|
+
async createdAt() {
|
|
2939
|
+
await this._open();
|
|
2940
|
+
return this._createdAt;
|
|
2941
|
+
}
|
|
2942
|
+
/** True if an envelope signed at `signedAt` (ms) predates this cache. */
|
|
2943
|
+
async signedBeforeCache(signedAt) {
|
|
2944
|
+
return signedAt < await this.createdAt();
|
|
2945
|
+
}
|
|
2946
|
+
/** Number of ids currently held on disk. */
|
|
2947
|
+
async count() {
|
|
2948
|
+
const db = await this._open();
|
|
2949
|
+
return this._countRows(db);
|
|
2950
|
+
}
|
|
2951
|
+
/** Close the file; the next call opens it again. */
|
|
2952
|
+
async close() {
|
|
2953
|
+
await this._opening?.catch(() => void 0);
|
|
2954
|
+
this._db?.close();
|
|
2955
|
+
this._db = null;
|
|
2956
|
+
}
|
|
2957
|
+
_open() {
|
|
2958
|
+
if (this._db !== null) return Promise.resolve(this._db);
|
|
2959
|
+
this._opening ??= this._connect().finally(() => {
|
|
2960
|
+
this._opening = null;
|
|
2961
|
+
});
|
|
2962
|
+
return this._opening;
|
|
2963
|
+
}
|
|
2964
|
+
async _connect() {
|
|
2965
|
+
mkdirSync3(dirname2(this._path), { recursive: true });
|
|
2966
|
+
const db = new Database2(this._path, { timeout: 3e4 });
|
|
2967
|
+
try {
|
|
2968
|
+
await enableWal(db);
|
|
2969
|
+
db.exec(SCHEMA);
|
|
2970
|
+
db.prepare(
|
|
2971
|
+
"INSERT OR IGNORE INTO replay_meta (key, value) VALUES ('created_at', ?)"
|
|
2972
|
+
).run(String(this._now() / 1e3));
|
|
2973
|
+
const row = db.prepare("SELECT value FROM replay_meta WHERE key = 'created_at'").get();
|
|
2974
|
+
this._createdAt = Number(row.value) * 1e3;
|
|
2975
|
+
this._purge(db);
|
|
2976
|
+
} catch (err) {
|
|
2977
|
+
db.close();
|
|
2978
|
+
throw err;
|
|
2979
|
+
}
|
|
2980
|
+
this._db = db;
|
|
2981
|
+
return db;
|
|
2982
|
+
}
|
|
2983
|
+
_purge(db) {
|
|
2984
|
+
const now = this._now();
|
|
2985
|
+
db.prepare("DELETE FROM seen_envelopes WHERE seen_at < ?").run(
|
|
2986
|
+
now / 1e3 - RETENTION_SECONDS
|
|
2987
|
+
);
|
|
2988
|
+
this._count = this._countRows(db);
|
|
2989
|
+
this._lastPurge = now;
|
|
2990
|
+
}
|
|
2991
|
+
_countRows(db) {
|
|
2992
|
+
const row = db.prepare("SELECT COUNT(*) AS n FROM seen_envelopes").get();
|
|
2993
|
+
return row.n;
|
|
2994
|
+
}
|
|
2995
|
+
};
|
|
2996
|
+
|
|
1661
2997
|
// src/sdk/agent.ts
|
|
2998
|
+
var RELAY_NO_MEMORY_MS = 6e4;
|
|
2999
|
+
function replayCacheCapacityFromEnv() {
|
|
3000
|
+
const raw = process.env["UAM_REPLAY_CACHE_CAPACITY"];
|
|
3001
|
+
if (raw === void 0 || raw === "") {
|
|
3002
|
+
return DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
3003
|
+
}
|
|
3004
|
+
const n = Number(raw);
|
|
3005
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
3006
|
+
log.warn(
|
|
3007
|
+
`Ignoring invalid UAM_REPLAY_CACHE_CAPACITY=${JSON.stringify(raw)}; using ${DEFAULT_REPLAY_CACHE_CAPACITY}`
|
|
3008
|
+
);
|
|
3009
|
+
return DEFAULT_REPLAY_CACHE_CAPACITY;
|
|
3010
|
+
}
|
|
3011
|
+
return n;
|
|
3012
|
+
}
|
|
1662
3013
|
var Agent = class {
|
|
1663
3014
|
/** @internal */
|
|
1664
3015
|
_config;
|
|
@@ -1675,6 +3026,23 @@ var Agent = class {
|
|
|
1675
3026
|
_contactBook;
|
|
1676
3027
|
/** @internal */
|
|
1677
3028
|
_handshake;
|
|
3029
|
+
/**
|
|
3030
|
+
* @internal Wall clock in ms since the epoch, used by the inbound
|
|
3031
|
+
* freshness/expiry checks and the replay cache. Replaceable in tests.
|
|
3032
|
+
*/
|
|
3033
|
+
_clock = () => Date.now();
|
|
3034
|
+
/**
|
|
3035
|
+
* @internal Inbound replay cache, keyed by (from address, message id).
|
|
3036
|
+
* Kept on disk under the data dir, so it outlives restarts and is shared by
|
|
3037
|
+
* every process using it; capacity from UAM_REPLAY_CACHE_CAPACITY.
|
|
3038
|
+
*/
|
|
3039
|
+
_replayCache;
|
|
3040
|
+
/** Whether our relay has said it writes relay_meta. */
|
|
3041
|
+
_relayStamps = false;
|
|
3042
|
+
/** Until when, by _monotonic, to take the relay's last no as still true. */
|
|
3043
|
+
_relayNoUntil = 0;
|
|
3044
|
+
/** @internal Monotonic clock in ms. Replaceable in tests. */
|
|
3045
|
+
_monotonic = () => performance.now();
|
|
1678
3046
|
/**
|
|
1679
3047
|
* Create an Agent. No I/O happens here -- call connect() to initialize.
|
|
1680
3048
|
*/
|
|
@@ -1696,6 +3064,13 @@ var Agent = class {
|
|
|
1696
3064
|
this._contactBook,
|
|
1697
3065
|
this._config.trustPolicy
|
|
1698
3066
|
);
|
|
3067
|
+
this._replayCache = new PersistentReplayCache(
|
|
3068
|
+
join4(this._config.dataDir, "replay", "replay.db"),
|
|
3069
|
+
{
|
|
3070
|
+
capacity: replayCacheCapacityFromEnv(),
|
|
3071
|
+
now: () => this._clock()
|
|
3072
|
+
}
|
|
3073
|
+
);
|
|
1699
3074
|
}
|
|
1700
3075
|
// -- Properties ----------------------------------------------------------
|
|
1701
3076
|
/** The agent's full UAM address (e.g., 'myagent::youam.network'). */
|
|
@@ -1709,7 +3084,7 @@ var Agent = class {
|
|
|
1709
3084
|
}
|
|
1710
3085
|
/** UAM SDK version. */
|
|
1711
3086
|
get version() {
|
|
1712
|
-
return
|
|
3087
|
+
return SDK_VERSION;
|
|
1713
3088
|
}
|
|
1714
3089
|
/** The agent's public key (base64-encoded Ed25519 verify key). */
|
|
1715
3090
|
get publicKey() {
|
|
@@ -1746,10 +3121,19 @@ var Agent = class {
|
|
|
1746
3121
|
}
|
|
1747
3122
|
await sodiumReady;
|
|
1748
3123
|
this._keyManager.loadOrGenerate(this._config.name);
|
|
1749
|
-
|
|
3124
|
+
try {
|
|
3125
|
+
await this._replayCache.open();
|
|
3126
|
+
} catch (err) {
|
|
3127
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3128
|
+
log.warn(`Could not open the replay cache: ${reason}`);
|
|
3129
|
+
}
|
|
3130
|
+
const storedToken = this._keyManager.loadToken(
|
|
3131
|
+
this._config.name,
|
|
3132
|
+
this._config.relayUrl
|
|
3133
|
+
);
|
|
1750
3134
|
if (storedToken) {
|
|
1751
3135
|
this._token = storedToken;
|
|
1752
|
-
this.
|
|
3136
|
+
this._useAddress(await this._storedAddress());
|
|
1753
3137
|
} else if (this._autoRegister) {
|
|
1754
3138
|
await this._registerWithRelay();
|
|
1755
3139
|
} else {
|
|
@@ -1772,6 +3156,7 @@ var Agent = class {
|
|
|
1772
3156
|
*/
|
|
1773
3157
|
async close() {
|
|
1774
3158
|
this._contactBook.close();
|
|
3159
|
+
await this._replayCache.close();
|
|
1775
3160
|
if (this._transport) {
|
|
1776
3161
|
await this._transport.disconnect();
|
|
1777
3162
|
}
|
|
@@ -1779,7 +3164,12 @@ var Agent = class {
|
|
|
1779
3164
|
}
|
|
1780
3165
|
// -- Messaging -----------------------------------------------------------
|
|
1781
3166
|
/**
|
|
1782
|
-
* Send an encrypted, signed message.
|
|
3167
|
+
* Send an encrypted, signed message.
|
|
3168
|
+
*
|
|
3169
|
+
* Returns the message id with what the relay did with it: `status`
|
|
3170
|
+
* "pending" means the recipient only takes approved contacts, and the
|
|
3171
|
+
* message was not kept (don't resend it). `delivered` is true only when
|
|
3172
|
+
* the relay says the recipient has it.
|
|
1783
3173
|
*/
|
|
1784
3174
|
async send(toAddress, message, options) {
|
|
1785
3175
|
await this._ensureConnected();
|
|
@@ -1792,7 +3182,8 @@ var Agent = class {
|
|
|
1792
3182
|
}
|
|
1793
3183
|
}
|
|
1794
3184
|
const recipientVk = await this._resolvePublicKey(toAddress);
|
|
1795
|
-
|
|
3185
|
+
const trustState = this._contactBook.getTrustState(toAddress);
|
|
3186
|
+
if (trustState !== "pinned" && trustState !== "verified" && trustState !== "trusted" && trustState !== "handshake-sent") {
|
|
1796
3187
|
await this._initiateHandshake(toAddress, recipientVk);
|
|
1797
3188
|
}
|
|
1798
3189
|
const envelope = await createEnvelope(
|
|
@@ -1810,20 +3201,29 @@ var Agent = class {
|
|
|
1810
3201
|
);
|
|
1811
3202
|
const wire = toWireDict(envelope);
|
|
1812
3203
|
const relayUrls = await this._getRelayUrls(toAddress);
|
|
3204
|
+
let answer;
|
|
1813
3205
|
if (relayUrls !== null && relayUrls.length === 1 && relayUrls[0] === this._config.relayUrl) {
|
|
1814
|
-
await this._transport.send(wire);
|
|
3206
|
+
answer = await this._transport.send(wire);
|
|
1815
3207
|
} else if (relayUrls !== null && relayUrls.length > 0) {
|
|
1816
|
-
await this._trySendWithFailover(wire, relayUrls);
|
|
3208
|
+
answer = await this._trySendWithFailover(wire, relayUrls);
|
|
1817
3209
|
} else {
|
|
1818
|
-
await this._transport.send(wire);
|
|
3210
|
+
answer = await this._transport.send(wire);
|
|
1819
3211
|
}
|
|
1820
|
-
|
|
3212
|
+
const status = sendStatus(answer);
|
|
3213
|
+
return {
|
|
3214
|
+
messageId: envelope.messageId,
|
|
3215
|
+
status,
|
|
3216
|
+
delivered: status === "delivered"
|
|
3217
|
+
};
|
|
1821
3218
|
}
|
|
1822
3219
|
/**
|
|
1823
3220
|
* Retrieve, decrypt, and verify pending messages.
|
|
1824
3221
|
*
|
|
1825
3222
|
* Returns a list of ReceivedMessage data objects.
|
|
1826
|
-
*
|
|
3223
|
+
* Drops messages with invalid signatures or decryption errors. A message
|
|
3224
|
+
* that fails processing for any other reason (malformed envelope, bad
|
|
3225
|
+
* handshake payload, ...) is logged and dropped; it never aborts the
|
|
3226
|
+
* rest of the batch.
|
|
1827
3227
|
*/
|
|
1828
3228
|
async inbox(limit = 50) {
|
|
1829
3229
|
await this._ensureConnected();
|
|
@@ -1831,7 +3231,15 @@ var Agent = class {
|
|
|
1831
3231
|
const rawMessages = await this._transport.receive(limit);
|
|
1832
3232
|
const result = [];
|
|
1833
3233
|
for (const raw of rawMessages) {
|
|
1834
|
-
|
|
3234
|
+
let msg;
|
|
3235
|
+
try {
|
|
3236
|
+
msg = await this._processInbound(raw);
|
|
3237
|
+
} catch (err) {
|
|
3238
|
+
log.warn(
|
|
3239
|
+
`Dropping inbound message ${String(raw["message_id"])} from ${String(raw["from"])}: ${err instanceof Error ? err.message : String(err)}`
|
|
3240
|
+
);
|
|
3241
|
+
continue;
|
|
3242
|
+
}
|
|
1835
3243
|
if (msg !== null) {
|
|
1836
3244
|
result.push(msg);
|
|
1837
3245
|
await this._sendReadReceipt(msg);
|
|
@@ -1851,42 +3259,43 @@ var Agent = class {
|
|
|
1851
3259
|
* Adds the sender to contacts with trust_source='explicit-approval'
|
|
1852
3260
|
* and sends handshake.accept back.
|
|
1853
3261
|
*/
|
|
1854
|
-
async approve(
|
|
3262
|
+
async approve(address2) {
|
|
1855
3263
|
await this._ensureConnected();
|
|
1856
3264
|
const pendingList = this._contactBook.getPending();
|
|
1857
|
-
const entry = pendingList.find((p) => p.address ===
|
|
3265
|
+
const entry = pendingList.find((p) => p.address === address2);
|
|
1858
3266
|
if (!entry) {
|
|
1859
|
-
throw new UAMError(`No pending handshake from ${
|
|
3267
|
+
throw new UAMError(`No pending handshake from ${address2}`);
|
|
1860
3268
|
}
|
|
1861
3269
|
const cardDict = JSON.parse(entry.contactCard);
|
|
1862
3270
|
const card = contactCardFromDict(cardDict);
|
|
1863
3271
|
verifyContactCard(card);
|
|
3272
|
+
const senderVk = deserializeVerifyKey(card.publicKey);
|
|
3273
|
+
bindCardToEnvelope(card, { fromAddress: address2 }, senderVk);
|
|
1864
3274
|
this._contactBook.addContact(card.address, card.publicKey, {
|
|
1865
3275
|
displayName: card.displayName,
|
|
1866
3276
|
trustState: "trusted",
|
|
1867
3277
|
trustSource: "explicit-approval"
|
|
1868
3278
|
});
|
|
1869
|
-
this._contactBook.removePending(
|
|
1870
|
-
|
|
1871
|
-
await this._handshake._sendAccept(this, address, senderVk);
|
|
3279
|
+
this._contactBook.removePending(address2);
|
|
3280
|
+
await this._handshake._sendAccept(this, address2, senderVk);
|
|
1872
3281
|
}
|
|
1873
3282
|
/**
|
|
1874
3283
|
* Deny a pending handshake request.
|
|
1875
3284
|
*
|
|
1876
3285
|
* Removes the entry from pending and sends handshake.deny.
|
|
1877
3286
|
*/
|
|
1878
|
-
async deny(
|
|
3287
|
+
async deny(address2) {
|
|
1879
3288
|
await this._ensureConnected();
|
|
1880
3289
|
const pendingList = this._contactBook.getPending();
|
|
1881
|
-
const entry = pendingList.find((p) => p.address ===
|
|
3290
|
+
const entry = pendingList.find((p) => p.address === address2);
|
|
1882
3291
|
if (!entry) {
|
|
1883
|
-
throw new UAMError(`No pending handshake from ${
|
|
3292
|
+
throw new UAMError(`No pending handshake from ${address2}`);
|
|
1884
3293
|
}
|
|
1885
3294
|
const cardDict = JSON.parse(entry.contactCard);
|
|
1886
3295
|
const card = contactCardFromDict(cardDict);
|
|
1887
3296
|
const senderVk = deserializeVerifyKey(card.publicKey);
|
|
1888
|
-
this._contactBook.removePending(
|
|
1889
|
-
await this._handshake._sendDeny(this,
|
|
3297
|
+
this._contactBook.removePending(address2);
|
|
3298
|
+
await this._handshake._sendDeny(this, address2, senderVk);
|
|
1890
3299
|
}
|
|
1891
3300
|
/**
|
|
1892
3301
|
* Block an address or domain pattern (HAND-04).
|
|
@@ -1912,7 +3321,7 @@ var Agent = class {
|
|
|
1912
3321
|
*
|
|
1913
3322
|
* Returns `true` if verified, `false` on timeout.
|
|
1914
3323
|
*/
|
|
1915
|
-
async verifyDomain(
|
|
3324
|
+
async verifyDomain(domain2, options) {
|
|
1916
3325
|
const { generateTxtRecord: generateTxtRecord2 } = await Promise.resolve().then(() => (init_dns_verifier(), dns_verifier_exports));
|
|
1917
3326
|
await this._ensureConnected();
|
|
1918
3327
|
const timeout = options?.timeout ?? 3e5;
|
|
@@ -1933,7 +3342,7 @@ var Agent = class {
|
|
|
1933
3342
|
"Content-Type": "application/json",
|
|
1934
3343
|
Authorization: `Bearer ${this._token}`
|
|
1935
3344
|
},
|
|
1936
|
-
body: JSON.stringify({ domain }),
|
|
3345
|
+
body: JSON.stringify({ domain: domain2 }),
|
|
1937
3346
|
signal: AbortSignal.timeout(3e4)
|
|
1938
3347
|
}
|
|
1939
3348
|
);
|
|
@@ -1986,7 +3395,7 @@ var Agent = class {
|
|
|
1986
3395
|
if (!resp.ok) {
|
|
1987
3396
|
throw new Error(`HTTP ${resp.status}`);
|
|
1988
3397
|
}
|
|
1989
|
-
return;
|
|
3398
|
+
return await readAnswer(resp);
|
|
1990
3399
|
} catch (err) {
|
|
1991
3400
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1992
3401
|
}
|
|
@@ -2004,6 +3413,10 @@ var Agent = class {
|
|
|
2004
3413
|
/**
|
|
2005
3414
|
* Resolve a recipient's public key, checking contact book first.
|
|
2006
3415
|
*
|
|
3416
|
+
* Pure lookup: never writes to the contact book. The contact row is
|
|
3417
|
+
* created by _initiateHandshake (as "handshake-sent") once the request
|
|
3418
|
+
* has gone out, giving the lifecycle no row -> handshake-sent -> pinned.
|
|
3419
|
+
*
|
|
2007
3420
|
* TOFU-01: Known contacts always use stored key (zero network I/O).
|
|
2008
3421
|
* TOFU-03: Race-condition double-check after network resolve.
|
|
2009
3422
|
* Raises KeyPinningError if a pinned contact's key doesn't match.
|
|
@@ -2027,9 +3440,6 @@ var Agent = class {
|
|
|
2027
3440
|
);
|
|
2028
3441
|
}
|
|
2029
3442
|
}
|
|
2030
|
-
this._contactBook.addContact(toAddress, resolvedPkStr, {
|
|
2031
|
-
trustState: "provisional"
|
|
2032
|
-
});
|
|
2033
3443
|
return deserializeVerifyKey(resolvedPkStr);
|
|
2034
3444
|
}
|
|
2035
3445
|
async _initiateHandshake(toAddress, recipientVk) {
|
|
@@ -2076,6 +3486,9 @@ var Agent = class {
|
|
|
2076
3486
|
}
|
|
2077
3487
|
throw err;
|
|
2078
3488
|
}
|
|
3489
|
+
if (!await this._passesInboundChecks(envelope, raw)) {
|
|
3490
|
+
return null;
|
|
3491
|
+
}
|
|
2079
3492
|
if (envelope.type === "handshake.request" /* HANDSHAKE_REQUEST */ || envelope.type === "handshake.accept" /* HANDSHAKE_ACCEPT */ || envelope.type === "handshake.deny" /* HANDSHAKE_DENY */) {
|
|
2080
3493
|
return this._handshake.handleInbound(this, envelope, senderVk);
|
|
2081
3494
|
}
|
|
@@ -2110,6 +3523,102 @@ var Agent = class {
|
|
|
2110
3523
|
verified: true
|
|
2111
3524
|
});
|
|
2112
3525
|
}
|
|
3526
|
+
/**
|
|
3527
|
+
* Recipient, freshness, expiry and replay checks for a signature-verified
|
|
3528
|
+
* inbound envelope. Returns false (and logs) when it must be dropped.
|
|
3529
|
+
* Matches the Python SDK's _process_inbound checks.
|
|
3530
|
+
*/
|
|
3531
|
+
async _passesInboundChecks(envelope, raw) {
|
|
3532
|
+
const id = envelope.messageId;
|
|
3533
|
+
const from = envelope.fromAddress;
|
|
3534
|
+
if (this._address !== null && envelope.toAddress !== this._address) {
|
|
3535
|
+
log.warn(
|
|
3536
|
+
`Dropped envelope ${id} from ${from} addressed to ${envelope.toAddress} (we are ${this._address})`
|
|
3537
|
+
);
|
|
3538
|
+
return false;
|
|
3539
|
+
}
|
|
3540
|
+
const now = this._clock();
|
|
3541
|
+
const ts = Date.parse(envelope.timestamp);
|
|
3542
|
+
if (Number.isNaN(ts)) {
|
|
3543
|
+
log.warn(
|
|
3544
|
+
`Dropped envelope ${id} from ${from} with malformed timestamp ` + JSON.stringify(envelope.timestamp)
|
|
3545
|
+
);
|
|
3546
|
+
return false;
|
|
3547
|
+
}
|
|
3548
|
+
const ageSeconds = Math.abs(now - ts) / 1e3;
|
|
3549
|
+
if (ageSeconds > MAX_ENVELOPE_AGE && !await this._wasStored(raw, envelope.toAddress, ts, now)) {
|
|
3550
|
+
log.info(
|
|
3551
|
+
`Dropped stale envelope ${id} from ${from} (age=${Math.round(ageSeconds)}s > ${MAX_ENVELOPE_AGE}s)`
|
|
3552
|
+
);
|
|
3553
|
+
return false;
|
|
3554
|
+
}
|
|
3555
|
+
if (envelope.expires !== void 0 && envelope.expires !== null) {
|
|
3556
|
+
const exp = Date.parse(envelope.expires);
|
|
3557
|
+
if (Number.isNaN(exp)) {
|
|
3558
|
+
log.warn(
|
|
3559
|
+
`Dropped envelope ${id} from ${from} with malformed expires ` + JSON.stringify(envelope.expires)
|
|
3560
|
+
);
|
|
3561
|
+
return false;
|
|
3562
|
+
}
|
|
3563
|
+
if (now > exp) {
|
|
3564
|
+
log.info(
|
|
3565
|
+
`Dropped expired envelope ${id} from ${from} (expires=${envelope.expires})`
|
|
3566
|
+
);
|
|
3567
|
+
return false;
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
if (await this._replayCache.seenOrRecord(from, id, ts)) {
|
|
3571
|
+
log.info(`Dropped replayed envelope ${id} from ${from}`);
|
|
3572
|
+
return false;
|
|
3573
|
+
}
|
|
3574
|
+
return true;
|
|
3575
|
+
}
|
|
3576
|
+
/** True if a message too old to be live is stored mail, not a replay. */
|
|
3577
|
+
async _wasStored(raw, toAddress, signedAt, now) {
|
|
3578
|
+
const relayDomain = toAddress.split("::").pop();
|
|
3579
|
+
if (!relayVouches(raw, signedAt, now, relayDomain)) return false;
|
|
3580
|
+
if (await this._replayCache.signedBeforeCache(signedAt)) return false;
|
|
3581
|
+
return this._relayStampsMail();
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* True if our relay writes relay_meta, so a sender can't have.
|
|
3585
|
+
*
|
|
3586
|
+
* Asked only when a stale message carries a stamp. A yes is remembered for
|
|
3587
|
+
* good, a no for RELAY_NO_MEMORY_MS: a relay can be upgraded while this
|
|
3588
|
+
* agent runs. A failed lookup isn't remembered, because the relay has
|
|
3589
|
+
* already marked this mail delivered and the next message should get a
|
|
3590
|
+
* real answer.
|
|
3591
|
+
*/
|
|
3592
|
+
async _relayStampsMail() {
|
|
3593
|
+
if (this._relayStamps) return true;
|
|
3594
|
+
if (this._monotonic() < this._relayNoUntil) return false;
|
|
3595
|
+
try {
|
|
3596
|
+
const features = await this._fetchRelayFeatures();
|
|
3597
|
+
this._relayStamps = features.includes(RELAY_META_FEATURE);
|
|
3598
|
+
if (!this._relayStamps) {
|
|
3599
|
+
this._relayNoUntil = this._monotonic() + RELAY_NO_MEMORY_MS;
|
|
3600
|
+
}
|
|
3601
|
+
} catch (err) {
|
|
3602
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3603
|
+
log.warn(`Cannot read relay features: ${reason}`);
|
|
3604
|
+
}
|
|
3605
|
+
return this._relayStamps;
|
|
3606
|
+
}
|
|
3607
|
+
/** The relay's features; a body that doesn't list any is an empty list. */
|
|
3608
|
+
async _fetchRelayFeatures() {
|
|
3609
|
+
const resp = await fetch(`${this._config.relayUrl}/api/v1/versions`, {
|
|
3610
|
+
signal: AbortSignal.timeout(1e4)
|
|
3611
|
+
});
|
|
3612
|
+
if (!resp.ok) throw new UAMError(`Relay answered ${resp.status}`);
|
|
3613
|
+
const body = await resp.text();
|
|
3614
|
+
let data;
|
|
3615
|
+
try {
|
|
3616
|
+
data = JSON.parse(body);
|
|
3617
|
+
} catch {
|
|
3618
|
+
return [];
|
|
3619
|
+
}
|
|
3620
|
+
return Array.isArray(data?.features) ? data.features : [];
|
|
3621
|
+
}
|
|
2113
3622
|
/**
|
|
2114
3623
|
* Sweep expired pending handshakes and send receipt.failed (HAND-03).
|
|
2115
3624
|
*/
|
|
@@ -2162,7 +3671,8 @@ var Agent = class {
|
|
|
2162
3671
|
"receipt.read" /* RECEIPT_READ */,
|
|
2163
3672
|
new TextEncoder().encode(receiptPayload),
|
|
2164
3673
|
this._keyManager.signingKey,
|
|
2165
|
-
senderVk
|
|
3674
|
+
senderVk,
|
|
3675
|
+
{ replyTo: msg.messageId }
|
|
2166
3676
|
);
|
|
2167
3677
|
const wire = toWireDict(envelope);
|
|
2168
3678
|
await this._transport.send(wire);
|
|
@@ -2172,26 +3682,25 @@ var Agent = class {
|
|
|
2172
3682
|
/**
|
|
2173
3683
|
* Register with the relay server.
|
|
2174
3684
|
*
|
|
2175
|
-
* Calls POST /api/v1/register with agent name and public key.
|
|
2176
|
-
*
|
|
3685
|
+
* Calls POST /api/v1/register with agent name and public key. If the
|
|
3686
|
+
* address exists (409), repeats it with a proof that this agent holds the
|
|
3687
|
+
* key. Stores the returned API key on disk.
|
|
2177
3688
|
*/
|
|
2178
3689
|
async _registerWithRelay() {
|
|
2179
|
-
const
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
headers: { "Content-Type": "application/json" },
|
|
2185
|
-
body: JSON.stringify({
|
|
2186
|
-
agent_name: this._config.name,
|
|
2187
|
-
public_key: publicKeyStr
|
|
2188
|
-
})
|
|
2189
|
-
}
|
|
2190
|
-
);
|
|
3690
|
+
const body = {
|
|
3691
|
+
agent_name: this._config.name,
|
|
3692
|
+
public_key: serializeVerifyKey(this._keyManager.verifyKey)
|
|
3693
|
+
};
|
|
3694
|
+
let resp = await this._postRegistration(body);
|
|
2191
3695
|
if (resp.status === 409) {
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
3696
|
+
const address3 = await this._addressOnRelay();
|
|
3697
|
+
resp = await this._postRegistration({
|
|
3698
|
+
...body,
|
|
3699
|
+
proof: this._registrationProof(address3)
|
|
3700
|
+
});
|
|
3701
|
+
if (resp.status === 409 || resp.status === 403) {
|
|
3702
|
+
throw this._ownershipNotProven(address3);
|
|
3703
|
+
}
|
|
2195
3704
|
}
|
|
2196
3705
|
if (!resp.ok) {
|
|
2197
3706
|
const text = await resp.text();
|
|
@@ -2200,19 +3709,156 @@ var Agent = class {
|
|
|
2200
3709
|
);
|
|
2201
3710
|
}
|
|
2202
3711
|
const data = await resp.json();
|
|
2203
|
-
|
|
3712
|
+
const address2 = data["address"];
|
|
3713
|
+
this._useAddress(address2);
|
|
2204
3714
|
this._token = data["token"];
|
|
2205
|
-
|
|
3715
|
+
const { name } = this._config;
|
|
3716
|
+
const token = this._token;
|
|
3717
|
+
this._tryToSave("token", () => this._keyManager.saveToken(name, token));
|
|
3718
|
+
this._saveAddress(address2);
|
|
3719
|
+
}
|
|
3720
|
+
/** This agent's address on the relay, for signing a proof. */
|
|
3721
|
+
async _addressOnRelay() {
|
|
3722
|
+
const { name, explicitRelayDomain, relayDomain, relayUrl } = this._config;
|
|
3723
|
+
if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
|
|
3724
|
+
const domain2 = await this._fetchRelayDomain();
|
|
3725
|
+
if (domain2 === null) return `${name}::${relayDomain}`;
|
|
3726
|
+
if (!this._relayMayUse(domain2)) {
|
|
3727
|
+
throw new UAMError(
|
|
3728
|
+
`The relay at ${relayUrl} says its domain is ${domain2}. Pass the domain option as ${JSON.stringify(domain2)} if that is right.`
|
|
3729
|
+
);
|
|
3730
|
+
}
|
|
3731
|
+
return `${name}::${domain2}`;
|
|
3732
|
+
}
|
|
3733
|
+
/**
|
|
3734
|
+
* True if the relay's URL fits `domain`, so a proof can be signed for it.
|
|
3735
|
+
*
|
|
3736
|
+
* A proof names an address on one relay, so it is only signed for a domain
|
|
3737
|
+
* this URL belongs to. A relay on this machine may use any domain.
|
|
3738
|
+
*/
|
|
3739
|
+
_relayMayUse(domain2) {
|
|
3740
|
+
let host;
|
|
3741
|
+
try {
|
|
3742
|
+
host = new URL(this._config.relayUrl).hostname.toLowerCase();
|
|
3743
|
+
} catch {
|
|
3744
|
+
return false;
|
|
3745
|
+
}
|
|
3746
|
+
const wanted = domain2.toLowerCase();
|
|
3747
|
+
return host === wanted || host.endsWith(`.${wanted}`) || host === "localhost" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
|
|
3748
|
+
}
|
|
3749
|
+
_postRegistration(body) {
|
|
3750
|
+
return fetch(`${this._config.relayUrl}/api/v1/register`, {
|
|
3751
|
+
method: "POST",
|
|
3752
|
+
headers: { "Content-Type": "application/json" },
|
|
3753
|
+
body: JSON.stringify(body)
|
|
3754
|
+
});
|
|
3755
|
+
}
|
|
3756
|
+
/**
|
|
3757
|
+
* Sign a statement that this agent holds the key for `address`.
|
|
3758
|
+
*
|
|
3759
|
+
* The signed string must stay byte-identical to the Python SDK's.
|
|
3760
|
+
*/
|
|
3761
|
+
_registrationProof(address2) {
|
|
3762
|
+
const timestamp = utcTimestamp();
|
|
3763
|
+
const signature = signMessage(
|
|
3764
|
+
new TextEncoder().encode(`uam-register-v1
|
|
3765
|
+
${address2}
|
|
3766
|
+
${timestamp}`),
|
|
3767
|
+
this._keyManager.signingKey
|
|
3768
|
+
);
|
|
3769
|
+
return { timestamp, signature };
|
|
3770
|
+
}
|
|
3771
|
+
_ownershipNotProven(address2) {
|
|
3772
|
+
return new UAMError(
|
|
3773
|
+
`Address already registered, and this key could not prove ownership: ` + address2
|
|
3774
|
+
);
|
|
3775
|
+
}
|
|
3776
|
+
/** Remember `address` for this relay, if the key folder allows it. */
|
|
3777
|
+
_saveAddress(address2) {
|
|
3778
|
+
const { name, relayUrl } = this._config;
|
|
3779
|
+
this._tryToSave(
|
|
3780
|
+
"address",
|
|
3781
|
+
() => this._keyManager.saveAddress(name, address2, relayUrl)
|
|
3782
|
+
);
|
|
3783
|
+
}
|
|
3784
|
+
/** Saving is best effort: a key folder we can't write doesn't stop us. */
|
|
3785
|
+
_tryToSave(what, save) {
|
|
3786
|
+
try {
|
|
3787
|
+
save();
|
|
3788
|
+
} catch (err) {
|
|
3789
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3790
|
+
log.warn(`Could not save the ${what}: ${reason}`);
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
/** Take `address` as ours; its domain is this relay's real domain. */
|
|
3794
|
+
_useAddress(address2) {
|
|
3795
|
+
this._address = address2;
|
|
3796
|
+
const domain2 = parseAddress(address2).domain;
|
|
3797
|
+
this._config.relayDomain = domain2;
|
|
3798
|
+
if (this._resolver instanceof SmartResolver) {
|
|
3799
|
+
this._resolver.relayDomain = domain2;
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
/**
|
|
3803
|
+
* The address the stored token belongs to.
|
|
3804
|
+
*
|
|
3805
|
+
* An explicit domain settles it. Otherwise the relay chooses the domain,
|
|
3806
|
+
* and it need not be the relay's hostname: use the address saved for this
|
|
3807
|
+
* relay, or ask the relay once and keep the answer.
|
|
3808
|
+
*/
|
|
3809
|
+
async _storedAddress() {
|
|
3810
|
+
const { name, explicitRelayDomain } = this._config;
|
|
3811
|
+
if (explicitRelayDomain !== null) return `${name}::${explicitRelayDomain}`;
|
|
3812
|
+
const saved = this._keyManager.loadAddress(name, this._config.relayUrl);
|
|
3813
|
+
if (saved) return saved;
|
|
3814
|
+
const domain2 = await this._fetchRelayDomain();
|
|
3815
|
+
if (domain2 === null) return `${name}::${this._config.relayDomain}`;
|
|
3816
|
+
const address2 = `${name}::${domain2}`;
|
|
3817
|
+
this._saveAddress(address2);
|
|
3818
|
+
return address2;
|
|
3819
|
+
}
|
|
3820
|
+
/** Ask the relay for its domain; null if it cannot say. */
|
|
3821
|
+
async _fetchRelayDomain() {
|
|
3822
|
+
const url = `${this._config.relayUrl}/.well-known/uam-relay.json`;
|
|
3823
|
+
try {
|
|
3824
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(1e4) });
|
|
3825
|
+
if (!resp.ok) return null;
|
|
3826
|
+
const data = await resp.json();
|
|
3827
|
+
const domain2 = data["relay_domain"];
|
|
3828
|
+
if (typeof domain2 !== "string" || domain2 === "") return null;
|
|
3829
|
+
return parseAddress(`${this._config.name}::${domain2}`).domain;
|
|
3830
|
+
} catch {
|
|
3831
|
+
return null;
|
|
3832
|
+
}
|
|
2206
3833
|
}
|
|
2207
3834
|
};
|
|
2208
3835
|
|
|
2209
3836
|
// src/sdk/webhook-verify.ts
|
|
2210
3837
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
3838
|
+
function verifyWebhookSignature(payload, signatureHeader, secret2) {
|
|
3839
|
+
if (!signatureHeader.startsWith("sha256=")) {
|
|
3840
|
+
return false;
|
|
3841
|
+
}
|
|
3842
|
+
const expected = createHmac("sha256", secret2).update(payload).digest("hex");
|
|
3843
|
+
const received = signatureHeader.slice("sha256=".length);
|
|
3844
|
+
const expectedBuf = Buffer.from(expected, "utf-8");
|
|
3845
|
+
const receivedBuf = Buffer.from(received, "utf-8");
|
|
3846
|
+
if (expectedBuf.length !== receivedBuf.length) {
|
|
3847
|
+
return false;
|
|
3848
|
+
}
|
|
3849
|
+
return timingSafeEqual(expectedBuf, receivedBuf);
|
|
3850
|
+
}
|
|
2211
3851
|
|
|
2212
3852
|
// src/sdk/index.ts
|
|
2213
3853
|
init_dns_verifier();
|
|
2214
3854
|
export {
|
|
2215
3855
|
Agent,
|
|
2216
|
-
|
|
3856
|
+
ContactBook,
|
|
3857
|
+
PENDING_NOTE,
|
|
3858
|
+
SEND_STATUSES,
|
|
3859
|
+
pendingNote,
|
|
3860
|
+
protocol_exports as protocol,
|
|
3861
|
+
supports,
|
|
3862
|
+
verifyWebhookSignature
|
|
2217
3863
|
};
|
|
2218
3864
|
//# sourceMappingURL=index.js.map
|