@youam/sdk 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -1,9 +1,3 @@
1
- /**
2
- * SDK configuration (matching Python config.py).
3
- *
4
- * Priority (highest wins): constructor arg > env var > default.
5
- * TOML config file support is Python-only for now (unnecessary complexity for TS MVP).
6
- */
7
1
  declare class SDKConfig {
8
2
  readonly name: string;
9
3
  readonly relayUrl: string;
@@ -13,7 +7,9 @@ declare class SDKConfig {
13
7
  readonly displayName: string;
14
8
  readonly transportType: string;
15
9
  readonly trustPolicy: string;
16
- readonly relayDomain: string;
10
+ relayDomain: string;
11
+ /** The domain the caller chose, if any; null when only derived. */
12
+ readonly explicitRelayDomain: string | null;
17
13
  constructor(options: {
18
14
  name: string;
19
15
  relayUrl?: string | null;
@@ -34,6 +30,12 @@ declare class SDKConfig {
34
30
  declare const UAM_VERSION = "0.1";
35
31
  /** Maximum envelope size in bytes (64 KB). */
36
32
  declare const MAX_ENVELOPE_SIZE = 65536;
33
+ /**
34
+ * Maximum allowed skew, in seconds, between an inbound envelope's signed
35
+ * timestamp and the receiver's clock (either direction). Also the TTL of
36
+ * the inbound replay cache. Matches Python MAX_ENVELOPE_AGE.
37
+ */
38
+ declare const MAX_ENVELOPE_AGE = 300;
37
39
  /** All UAM message types. */
38
40
  declare enum MessageType {
39
41
  MESSAGE = "message",
@@ -399,6 +401,7 @@ declare const index_InvalidEnvelopeError: typeof InvalidEnvelopeError;
399
401
  type index_KeyPinningError = KeyPinningError;
400
402
  declare const index_KeyPinningError: typeof KeyPinningError;
401
403
  type index_Keypair = Keypair;
404
+ declare const index_MAX_ENVELOPE_AGE: typeof MAX_ENVELOPE_AGE;
402
405
  declare const index_MAX_ENVELOPE_SIZE: typeof MAX_ENVELOPE_SIZE;
403
406
  type index_MessageEnvelope = MessageEnvelope;
404
407
  type index_MessageType = MessageType;
@@ -442,7 +445,7 @@ declare const index_verifyContactCard: typeof verifyContactCard;
442
445
  declare const index_verifyEnvelope: typeof verifyEnvelope;
443
446
  declare const index_verifySignature: typeof verifySignature;
444
447
  declare namespace index {
445
- export { type index_Address as Address, type index_ContactCard as ContactCard, index_DecryptionError as DecryptionError, index_EncryptionError as EncryptionError, index_EnvelopeTooLargeError as EnvelopeTooLargeError, index_InvalidAddressError as InvalidAddressError, index_InvalidContactCardError as InvalidContactCardError, index_InvalidEnvelopeError as InvalidEnvelopeError, index_KeyPinningError as KeyPinningError, type index_Keypair as Keypair, index_MAX_ENVELOPE_SIZE as MAX_ENVELOPE_SIZE, type index_MessageEnvelope as MessageEnvelope, index_MessageType as MessageType, type index_Seed as Seed, index_SignatureError as SignatureError, index_SignatureVerificationError as SignatureVerificationError, type index_SigningKey as SigningKey, index_UAMError as UAMError, index_UAM_VERSION as UAM_VERSION, type index_VerifyKey as VerifyKey, index_b64Decode as b64Decode, index_b64Encode as b64Encode, index_canonicalize as canonicalize, index_contactCardFromDict as contactCardFromDict, index_contactCardToDict as contactCardToDict, index_createContactCard as createContactCard, index_createEnvelope as createEnvelope, index_decryptPayload as decryptPayload, index_decryptPayloadAnonymous as decryptPayloadAnonymous, index_deserializeSigningKey as deserializeSigningKey, index_deserializeVerifyKey as deserializeVerifyKey, index_encryptPayload as encryptPayload, index_encryptPayloadAnonymous as encryptPayloadAnonymous, index_fromWireDict as fromWireDict, index_generateKeypair as generateKeypair, index_generateNonce as generateNonce, index_parseAddress as parseAddress, index_publicKeyFingerprint as publicKeyFingerprint, index_serializeSigningKey as serializeSigningKey, index_serializeVerifyKey as serializeVerifyKey, index_signMessage as signMessage, index_sodiumReady as sodiumReady, index_toWireDict as toWireDict, index_utcTimestamp as utcTimestamp, index_validateEnvelopeSize as validateEnvelopeSize, index_verifyContactCard as verifyContactCard, index_verifyEnvelope as verifyEnvelope, index_verifySignature as verifySignature };
448
+ export { type index_Address as Address, type index_ContactCard as ContactCard, index_DecryptionError as DecryptionError, index_EncryptionError as EncryptionError, index_EnvelopeTooLargeError as EnvelopeTooLargeError, index_InvalidAddressError as InvalidAddressError, index_InvalidContactCardError as InvalidContactCardError, index_InvalidEnvelopeError as InvalidEnvelopeError, index_KeyPinningError as KeyPinningError, type index_Keypair as Keypair, index_MAX_ENVELOPE_AGE as MAX_ENVELOPE_AGE, index_MAX_ENVELOPE_SIZE as MAX_ENVELOPE_SIZE, type index_MessageEnvelope as MessageEnvelope, index_MessageType as MessageType, type index_Seed as Seed, index_SignatureError as SignatureError, index_SignatureVerificationError as SignatureVerificationError, type index_SigningKey as SigningKey, index_UAMError as UAMError, index_UAM_VERSION as UAM_VERSION, type index_VerifyKey as VerifyKey, index_b64Decode as b64Decode, index_b64Encode as b64Encode, index_canonicalize as canonicalize, index_contactCardFromDict as contactCardFromDict, index_contactCardToDict as contactCardToDict, index_createContactCard as createContactCard, index_createEnvelope as createEnvelope, index_decryptPayload as decryptPayload, index_decryptPayloadAnonymous as decryptPayloadAnonymous, index_deserializeSigningKey as deserializeSigningKey, index_deserializeVerifyKey as deserializeVerifyKey, index_encryptPayload as encryptPayload, index_encryptPayloadAnonymous as encryptPayloadAnonymous, index_fromWireDict as fromWireDict, index_generateKeypair as generateKeypair, index_generateNonce as generateNonce, index_parseAddress as parseAddress, index_publicKeyFingerprint as publicKeyFingerprint, index_serializeSigningKey as serializeSigningKey, index_serializeVerifyKey as serializeVerifyKey, index_signMessage as signMessage, index_sodiumReady as sodiumReady, index_toWireDict as toWireDict, index_utcTimestamp as utcTimestamp, index_validateEnvelopeSize as validateEnvelopeSize, index_verifyContactCard as verifyContactCard, index_verifyEnvelope as verifyEnvelope, index_verifySignature as verifySignature };
446
449
  }
447
450
 
448
451
  /**
@@ -474,13 +477,31 @@ declare class KeyManager {
474
477
  * Store the relay token alongside the keypair.
475
478
  */
476
479
  saveToken(name: string, token: string): void;
480
+ /**
481
+ * Store the address `relayUrl` gave this agent.
482
+ */
483
+ saveAddress(name: string, address: string, relayUrl: string): void;
484
+ /**
485
+ * Load the address saved for `relayUrl`.
486
+ *
487
+ * Returns null if there is none: the file is missing, unreadable or
488
+ * malformed, or it belongs to another relay.
489
+ */
490
+ loadAddress(name: string, relayUrl: string): string | null;
477
491
  /**
478
492
  * Load a previously saved token, or return null.
479
493
  *
480
494
  * Checks UAM_TOKEN env var first, then file-based storage,
481
- * then legacy .api_key files for backward compatibility.
495
+ * then legacy .api_key files for backward compatibility. With `relayUrl`,
496
+ * a saved token that belongs to another relay counts as none, so it is
497
+ * only offered to the relay that issued it.
498
+ */
499
+ loadToken(name: string, relayUrl?: string): string | null;
500
+ /**
501
+ * True if the saved address says this agent's files are another relay's.
502
+ * A missing or damaged file says nothing.
482
503
  */
483
- loadToken(name: string): string | null;
504
+ private _savedForAnotherRelay;
484
505
  /** Set file permissions to 0o600 (owner read/write only). */
485
506
  private _setPermissions;
486
507
  /** Warn if key file permissions are too permissive. */
@@ -583,6 +604,14 @@ declare class ContactBook {
583
604
  getRelayUrls(address: string): string[] | null;
584
605
  /**
585
606
  * Add or update a contact (upsert).
607
+ *
608
+ * Refuses to replace the key of a contact whose trust_state is "pinned"
609
+ * or "verified" with a different key, unless `force: true` is passed (for
610
+ * a deliberate key rotation, which is logged). Matches the Python SDK's
611
+ * ContactBook.add_contact.
612
+ *
613
+ * @throws {KeyPinningError} If a pinned/verified contact's key would be
614
+ * replaced by a different key without `force`.
586
615
  */
587
616
  addContact(address: string, publicKey: string, options?: {
588
617
  displayName?: string | null;
@@ -590,6 +619,7 @@ declare class ContactBook {
590
619
  trustSource?: string | null;
591
620
  relay?: string | null;
592
621
  relays?: string[] | null;
622
+ force?: boolean;
593
623
  }): void;
594
624
  /**
595
625
  * Return all contacts sorted by last_seen DESC.
@@ -694,6 +724,16 @@ declare class HandshakeManager {
694
724
  private _handleRequest;
695
725
  /**
696
726
  * Process a handshake.accept: store the sender as pinned (TOFU).
727
+ *
728
+ * Only honoured when:
729
+ * 1. we previously sent this address a handshake.request
730
+ * (trust_state === "handshake-sent"); anything else is an unsolicited
731
+ * accept and is dropped. This also means an accept can never
732
+ * downgrade a trusted/verified contact to pinned;
733
+ * 2. the payload decrypts with our key and the sender's key, and parses
734
+ * as JSON. Otherwise it is dropped without pinning;
735
+ * 3. an embedded contact_card, if present, is well-formed, correctly
736
+ * self-signed, and bound to the envelope sender (throws otherwise).
697
737
  */
698
738
  private _handleAccept;
699
739
  /**
@@ -731,6 +771,55 @@ declare abstract class TransportBase {
731
771
  abstract listen(callback: (msg: Record<string, unknown>) => Promise<void>): Promise<void>;
732
772
  }
733
773
 
774
+ /**
775
+ * Inbound (from address, message id) pairs kept in a SQLite file.
776
+ *
777
+ * Opens itself on first use, so an Agent driven without connect() still has
778
+ * replay protection. Safe to share between processes: the insert is the
779
+ * check (INSERT OR IGNORE), in WAL mode.
780
+ */
781
+ declare class PersistentReplayCache {
782
+ readonly capacity: number;
783
+ private readonly _path;
784
+ private readonly _now;
785
+ private _db;
786
+ private _opening;
787
+ private _count;
788
+ private _lastPurge;
789
+ private _createdAt;
790
+ private readonly _overflow;
791
+ /**
792
+ * @param path - The SQLite file
793
+ * @param options.now - The clock, in ms since the epoch
794
+ */
795
+ constructor(path: string, options?: {
796
+ capacity?: number;
797
+ now?: () => number;
798
+ });
799
+ /** Open the file now, rather than on first use. */
800
+ open(): Promise<void>;
801
+ /**
802
+ * Return true if the envelope must be dropped as a replay. Otherwise record
803
+ * the pair and return false.
804
+ *
805
+ * @param signedAt - The envelope's signed time, in ms since the epoch. It
806
+ * matters only when the cache is full.
807
+ */
808
+ seenOrRecord(fromAddress: string, messageId: string, signedAt: number): Promise<boolean>;
809
+ /** When this cache file was first created, in ms since the epoch. */
810
+ createdAt(): Promise<number>;
811
+ /** True if an envelope signed at `signedAt` (ms) predates this cache. */
812
+ signedBeforeCache(signedAt: number): Promise<boolean>;
813
+ /** Number of ids currently held on disk. */
814
+ count(): Promise<number>;
815
+ /** Close the file; the next call opens it again. */
816
+ close(): Promise<void>;
817
+ private _open;
818
+ private _connect;
819
+ private _purge;
820
+ private _countRows;
821
+ }
822
+
734
823
  /**
735
824
  * Agent class -- the primary SDK interface (SDK-01).
736
825
  *
@@ -756,6 +845,23 @@ declare class Agent {
756
845
  readonly _contactBook: ContactBook;
757
846
  /** @internal */
758
847
  readonly _handshake: HandshakeManager;
848
+ /**
849
+ * @internal Wall clock in ms since the epoch, used by the inbound
850
+ * freshness/expiry checks and the replay cache. Replaceable in tests.
851
+ */
852
+ _clock: () => number;
853
+ /**
854
+ * @internal Inbound replay cache, keyed by (from address, message id).
855
+ * Kept on disk under the data dir, so it outlives restarts and is shared by
856
+ * every process using it; capacity from UAM_REPLAY_CACHE_CAPACITY.
857
+ */
858
+ readonly _replayCache: PersistentReplayCache;
859
+ /** Whether our relay has said it writes relay_meta. */
860
+ private _relayStamps;
861
+ /** Until when, by _monotonic, to take the relay's last no as still true. */
862
+ private _relayNoUntil;
863
+ /** @internal Monotonic clock in ms. Replaceable in tests. */
864
+ _monotonic: () => number;
759
865
  /**
760
866
  * Create an Agent. No I/O happens here -- call connect() to initialize.
761
867
  */
@@ -801,7 +907,10 @@ declare class Agent {
801
907
  * Retrieve, decrypt, and verify pending messages.
802
908
  *
803
909
  * Returns a list of ReceivedMessage data objects.
804
- * Silently drops messages with invalid signatures or decryption errors.
910
+ * Drops messages with invalid signatures or decryption errors. A message
911
+ * that fails processing for any other reason (malformed envelope, bad
912
+ * handshake payload, ...) is logged and dropped; it never aborts the
913
+ * rest of the batch.
805
914
  */
806
915
  inbox(limit?: number): Promise<ReceivedMessage[]>;
807
916
  /** List pending handshake requests. */
@@ -857,6 +966,10 @@ declare class Agent {
857
966
  /**
858
967
  * Resolve a recipient's public key, checking contact book first.
859
968
  *
969
+ * Pure lookup: never writes to the contact book. The contact row is
970
+ * created by _initiateHandshake (as "handshake-sent") once the request
971
+ * has gone out, giving the lifecycle no row -> handshake-sent -> pinned.
972
+ *
860
973
  * TOFU-01: Known contacts always use stored key (zero network I/O).
861
974
  * TOFU-03: Race-condition double-check after network resolve.
862
975
  * Raises KeyPinningError if a pinned contact's key doesn't match.
@@ -869,6 +982,26 @@ declare class Agent {
869
982
  * Returns null for invalid messages or handshake protocol messages.
870
983
  */
871
984
  private _processInbound;
985
+ /**
986
+ * Recipient, freshness, expiry and replay checks for a signature-verified
987
+ * inbound envelope. Returns false (and logs) when it must be dropped.
988
+ * Matches the Python SDK's _process_inbound checks.
989
+ */
990
+ private _passesInboundChecks;
991
+ /** True if a message too old to be live is stored mail, not a replay. */
992
+ private _wasStored;
993
+ /**
994
+ * True if our relay writes relay_meta, so a sender can't have.
995
+ *
996
+ * Asked only when a stale message carries a stamp. A yes is remembered for
997
+ * good, a no for RELAY_NO_MEMORY_MS: a relay can be upgraded while this
998
+ * agent runs. A failed lookup isn't remembered, because the relay has
999
+ * already marked this mail delivered and the next message should get a
1000
+ * real answer.
1001
+ */
1002
+ private _relayStampsMail;
1003
+ /** The relay's features; a body that doesn't list any is an empty list. */
1004
+ private _fetchRelayFeatures;
872
1005
  /**
873
1006
  * Sweep expired pending handshakes and send receipt.failed (HAND-03).
874
1007
  */
@@ -883,10 +1016,80 @@ declare class Agent {
883
1016
  /**
884
1017
  * Register with the relay server.
885
1018
  *
886
- * Calls POST /api/v1/register with agent name and public key.
887
- * Stores the returned API key on disk.
1019
+ * Calls POST /api/v1/register with agent name and public key. If the
1020
+ * address exists (409), repeats it with a proof that this agent holds the
1021
+ * key. Stores the returned API key on disk.
888
1022
  */
889
1023
  private _registerWithRelay;
1024
+ /** This agent's address on the relay, for signing a proof. */
1025
+ private _addressOnRelay;
1026
+ /**
1027
+ * True if the relay's URL fits `domain`, so a proof can be signed for it.
1028
+ *
1029
+ * A proof names an address on one relay, so it is only signed for a domain
1030
+ * this URL belongs to. A relay on this machine may use any domain.
1031
+ */
1032
+ private _relayMayUse;
1033
+ private _postRegistration;
1034
+ /**
1035
+ * Sign a statement that this agent holds the key for `address`.
1036
+ *
1037
+ * The signed string must stay byte-identical to the Python SDK's.
1038
+ */
1039
+ private _registrationProof;
1040
+ private _ownershipNotProven;
1041
+ /** Remember `address` for this relay, if the key folder allows it. */
1042
+ private _saveAddress;
1043
+ /** Saving is best effort: a key folder we can't write doesn't stop us. */
1044
+ private _tryToSave;
1045
+ /** Take `address` as ours; its domain is this relay's real domain. */
1046
+ private _useAddress;
1047
+ /**
1048
+ * The address the stored token belongs to.
1049
+ *
1050
+ * An explicit domain settles it. Otherwise the relay chooses the domain,
1051
+ * and it need not be the relay's hostname: use the address saved for this
1052
+ * relay, or ask the relay once and keep the answer.
1053
+ */
1054
+ private _storedAddress;
1055
+ /** Ask the relay for its domain; null if it cannot say. */
1056
+ private _fetchRelayDomain;
890
1057
  }
891
1058
 
892
- export { Agent, type ReceivedMessage, index as protocol };
1059
+ /**
1060
+ * Receiver-side webhook signature verification (HOOK-03).
1061
+ *
1062
+ * Small utility for webhook receivers to verify that incoming webhook
1063
+ * payloads were signed by the UAM relay. Uses HMAC-SHA256 with the
1064
+ * agent's webhook secret, which the relay returns when the webhook is set
1065
+ * (PUT /api/v1/agents/{address}/webhook). Setting the same URL again
1066
+ * returns it again.
1067
+ *
1068
+ * Matches Python webhook_verify.py API surface.
1069
+ *
1070
+ * Usage:
1071
+ *
1072
+ * import { verifyWebhookSignature } from "@youam/sdk";
1073
+ *
1074
+ * const payload = Buffer.from(requestBody);
1075
+ * const signature = request.headers["x-uam-signature"];
1076
+ * const secret = "your-webhook-secret";
1077
+ *
1078
+ * if (verifyWebhookSignature(payload, signature, secret)) {
1079
+ * // payload is authentic
1080
+ * }
1081
+ */
1082
+ /**
1083
+ * Verify an HMAC-SHA256 webhook signature.
1084
+ *
1085
+ * @param payload - Raw request body bytes
1086
+ * @param signatureHeader - "sha256=<hex>" format (X-UAM-Signature header)
1087
+ * @param secret - The agent's webhook secret
1088
+ * @returns `true` if valid, `false` otherwise.
1089
+ *
1090
+ * Uses `crypto.timingSafeEqual()` for constant-time comparison
1091
+ * to prevent timing attacks.
1092
+ */
1093
+ declare function verifyWebhookSignature(payload: Buffer | Uint8Array, signatureHeader: string, secret: string): boolean;
1094
+
1095
+ export { Agent, ContactBook, type ReceivedMessage, index as protocol, verifyWebhookSignature };
package/dist/index.d.ts CHANGED
@@ -1,9 +1,3 @@
1
- /**
2
- * SDK configuration (matching Python config.py).
3
- *
4
- * Priority (highest wins): constructor arg > env var > default.
5
- * TOML config file support is Python-only for now (unnecessary complexity for TS MVP).
6
- */
7
1
  declare class SDKConfig {
8
2
  readonly name: string;
9
3
  readonly relayUrl: string;
@@ -13,7 +7,9 @@ declare class SDKConfig {
13
7
  readonly displayName: string;
14
8
  readonly transportType: string;
15
9
  readonly trustPolicy: string;
16
- readonly relayDomain: string;
10
+ relayDomain: string;
11
+ /** The domain the caller chose, if any; null when only derived. */
12
+ readonly explicitRelayDomain: string | null;
17
13
  constructor(options: {
18
14
  name: string;
19
15
  relayUrl?: string | null;
@@ -34,6 +30,12 @@ declare class SDKConfig {
34
30
  declare const UAM_VERSION = "0.1";
35
31
  /** Maximum envelope size in bytes (64 KB). */
36
32
  declare const MAX_ENVELOPE_SIZE = 65536;
33
+ /**
34
+ * Maximum allowed skew, in seconds, between an inbound envelope's signed
35
+ * timestamp and the receiver's clock (either direction). Also the TTL of
36
+ * the inbound replay cache. Matches Python MAX_ENVELOPE_AGE.
37
+ */
38
+ declare const MAX_ENVELOPE_AGE = 300;
37
39
  /** All UAM message types. */
38
40
  declare enum MessageType {
39
41
  MESSAGE = "message",
@@ -399,6 +401,7 @@ declare const index_InvalidEnvelopeError: typeof InvalidEnvelopeError;
399
401
  type index_KeyPinningError = KeyPinningError;
400
402
  declare const index_KeyPinningError: typeof KeyPinningError;
401
403
  type index_Keypair = Keypair;
404
+ declare const index_MAX_ENVELOPE_AGE: typeof MAX_ENVELOPE_AGE;
402
405
  declare const index_MAX_ENVELOPE_SIZE: typeof MAX_ENVELOPE_SIZE;
403
406
  type index_MessageEnvelope = MessageEnvelope;
404
407
  type index_MessageType = MessageType;
@@ -442,7 +445,7 @@ declare const index_verifyContactCard: typeof verifyContactCard;
442
445
  declare const index_verifyEnvelope: typeof verifyEnvelope;
443
446
  declare const index_verifySignature: typeof verifySignature;
444
447
  declare namespace index {
445
- export { type index_Address as Address, type index_ContactCard as ContactCard, index_DecryptionError as DecryptionError, index_EncryptionError as EncryptionError, index_EnvelopeTooLargeError as EnvelopeTooLargeError, index_InvalidAddressError as InvalidAddressError, index_InvalidContactCardError as InvalidContactCardError, index_InvalidEnvelopeError as InvalidEnvelopeError, index_KeyPinningError as KeyPinningError, type index_Keypair as Keypair, index_MAX_ENVELOPE_SIZE as MAX_ENVELOPE_SIZE, type index_MessageEnvelope as MessageEnvelope, index_MessageType as MessageType, type index_Seed as Seed, index_SignatureError as SignatureError, index_SignatureVerificationError as SignatureVerificationError, type index_SigningKey as SigningKey, index_UAMError as UAMError, index_UAM_VERSION as UAM_VERSION, type index_VerifyKey as VerifyKey, index_b64Decode as b64Decode, index_b64Encode as b64Encode, index_canonicalize as canonicalize, index_contactCardFromDict as contactCardFromDict, index_contactCardToDict as contactCardToDict, index_createContactCard as createContactCard, index_createEnvelope as createEnvelope, index_decryptPayload as decryptPayload, index_decryptPayloadAnonymous as decryptPayloadAnonymous, index_deserializeSigningKey as deserializeSigningKey, index_deserializeVerifyKey as deserializeVerifyKey, index_encryptPayload as encryptPayload, index_encryptPayloadAnonymous as encryptPayloadAnonymous, index_fromWireDict as fromWireDict, index_generateKeypair as generateKeypair, index_generateNonce as generateNonce, index_parseAddress as parseAddress, index_publicKeyFingerprint as publicKeyFingerprint, index_serializeSigningKey as serializeSigningKey, index_serializeVerifyKey as serializeVerifyKey, index_signMessage as signMessage, index_sodiumReady as sodiumReady, index_toWireDict as toWireDict, index_utcTimestamp as utcTimestamp, index_validateEnvelopeSize as validateEnvelopeSize, index_verifyContactCard as verifyContactCard, index_verifyEnvelope as verifyEnvelope, index_verifySignature as verifySignature };
448
+ export { type index_Address as Address, type index_ContactCard as ContactCard, index_DecryptionError as DecryptionError, index_EncryptionError as EncryptionError, index_EnvelopeTooLargeError as EnvelopeTooLargeError, index_InvalidAddressError as InvalidAddressError, index_InvalidContactCardError as InvalidContactCardError, index_InvalidEnvelopeError as InvalidEnvelopeError, index_KeyPinningError as KeyPinningError, type index_Keypair as Keypair, index_MAX_ENVELOPE_AGE as MAX_ENVELOPE_AGE, index_MAX_ENVELOPE_SIZE as MAX_ENVELOPE_SIZE, type index_MessageEnvelope as MessageEnvelope, index_MessageType as MessageType, type index_Seed as Seed, index_SignatureError as SignatureError, index_SignatureVerificationError as SignatureVerificationError, type index_SigningKey as SigningKey, index_UAMError as UAMError, index_UAM_VERSION as UAM_VERSION, type index_VerifyKey as VerifyKey, index_b64Decode as b64Decode, index_b64Encode as b64Encode, index_canonicalize as canonicalize, index_contactCardFromDict as contactCardFromDict, index_contactCardToDict as contactCardToDict, index_createContactCard as createContactCard, index_createEnvelope as createEnvelope, index_decryptPayload as decryptPayload, index_decryptPayloadAnonymous as decryptPayloadAnonymous, index_deserializeSigningKey as deserializeSigningKey, index_deserializeVerifyKey as deserializeVerifyKey, index_encryptPayload as encryptPayload, index_encryptPayloadAnonymous as encryptPayloadAnonymous, index_fromWireDict as fromWireDict, index_generateKeypair as generateKeypair, index_generateNonce as generateNonce, index_parseAddress as parseAddress, index_publicKeyFingerprint as publicKeyFingerprint, index_serializeSigningKey as serializeSigningKey, index_serializeVerifyKey as serializeVerifyKey, index_signMessage as signMessage, index_sodiumReady as sodiumReady, index_toWireDict as toWireDict, index_utcTimestamp as utcTimestamp, index_validateEnvelopeSize as validateEnvelopeSize, index_verifyContactCard as verifyContactCard, index_verifyEnvelope as verifyEnvelope, index_verifySignature as verifySignature };
446
449
  }
447
450
 
448
451
  /**
@@ -474,13 +477,31 @@ declare class KeyManager {
474
477
  * Store the relay token alongside the keypair.
475
478
  */
476
479
  saveToken(name: string, token: string): void;
480
+ /**
481
+ * Store the address `relayUrl` gave this agent.
482
+ */
483
+ saveAddress(name: string, address: string, relayUrl: string): void;
484
+ /**
485
+ * Load the address saved for `relayUrl`.
486
+ *
487
+ * Returns null if there is none: the file is missing, unreadable or
488
+ * malformed, or it belongs to another relay.
489
+ */
490
+ loadAddress(name: string, relayUrl: string): string | null;
477
491
  /**
478
492
  * Load a previously saved token, or return null.
479
493
  *
480
494
  * Checks UAM_TOKEN env var first, then file-based storage,
481
- * then legacy .api_key files for backward compatibility.
495
+ * then legacy .api_key files for backward compatibility. With `relayUrl`,
496
+ * a saved token that belongs to another relay counts as none, so it is
497
+ * only offered to the relay that issued it.
498
+ */
499
+ loadToken(name: string, relayUrl?: string): string | null;
500
+ /**
501
+ * True if the saved address says this agent's files are another relay's.
502
+ * A missing or damaged file says nothing.
482
503
  */
483
- loadToken(name: string): string | null;
504
+ private _savedForAnotherRelay;
484
505
  /** Set file permissions to 0o600 (owner read/write only). */
485
506
  private _setPermissions;
486
507
  /** Warn if key file permissions are too permissive. */
@@ -583,6 +604,14 @@ declare class ContactBook {
583
604
  getRelayUrls(address: string): string[] | null;
584
605
  /**
585
606
  * Add or update a contact (upsert).
607
+ *
608
+ * Refuses to replace the key of a contact whose trust_state is "pinned"
609
+ * or "verified" with a different key, unless `force: true` is passed (for
610
+ * a deliberate key rotation, which is logged). Matches the Python SDK's
611
+ * ContactBook.add_contact.
612
+ *
613
+ * @throws {KeyPinningError} If a pinned/verified contact's key would be
614
+ * replaced by a different key without `force`.
586
615
  */
587
616
  addContact(address: string, publicKey: string, options?: {
588
617
  displayName?: string | null;
@@ -590,6 +619,7 @@ declare class ContactBook {
590
619
  trustSource?: string | null;
591
620
  relay?: string | null;
592
621
  relays?: string[] | null;
622
+ force?: boolean;
593
623
  }): void;
594
624
  /**
595
625
  * Return all contacts sorted by last_seen DESC.
@@ -694,6 +724,16 @@ declare class HandshakeManager {
694
724
  private _handleRequest;
695
725
  /**
696
726
  * Process a handshake.accept: store the sender as pinned (TOFU).
727
+ *
728
+ * Only honoured when:
729
+ * 1. we previously sent this address a handshake.request
730
+ * (trust_state === "handshake-sent"); anything else is an unsolicited
731
+ * accept and is dropped. This also means an accept can never
732
+ * downgrade a trusted/verified contact to pinned;
733
+ * 2. the payload decrypts with our key and the sender's key, and parses
734
+ * as JSON. Otherwise it is dropped without pinning;
735
+ * 3. an embedded contact_card, if present, is well-formed, correctly
736
+ * self-signed, and bound to the envelope sender (throws otherwise).
697
737
  */
698
738
  private _handleAccept;
699
739
  /**
@@ -731,6 +771,55 @@ declare abstract class TransportBase {
731
771
  abstract listen(callback: (msg: Record<string, unknown>) => Promise<void>): Promise<void>;
732
772
  }
733
773
 
774
+ /**
775
+ * Inbound (from address, message id) pairs kept in a SQLite file.
776
+ *
777
+ * Opens itself on first use, so an Agent driven without connect() still has
778
+ * replay protection. Safe to share between processes: the insert is the
779
+ * check (INSERT OR IGNORE), in WAL mode.
780
+ */
781
+ declare class PersistentReplayCache {
782
+ readonly capacity: number;
783
+ private readonly _path;
784
+ private readonly _now;
785
+ private _db;
786
+ private _opening;
787
+ private _count;
788
+ private _lastPurge;
789
+ private _createdAt;
790
+ private readonly _overflow;
791
+ /**
792
+ * @param path - The SQLite file
793
+ * @param options.now - The clock, in ms since the epoch
794
+ */
795
+ constructor(path: string, options?: {
796
+ capacity?: number;
797
+ now?: () => number;
798
+ });
799
+ /** Open the file now, rather than on first use. */
800
+ open(): Promise<void>;
801
+ /**
802
+ * Return true if the envelope must be dropped as a replay. Otherwise record
803
+ * the pair and return false.
804
+ *
805
+ * @param signedAt - The envelope's signed time, in ms since the epoch. It
806
+ * matters only when the cache is full.
807
+ */
808
+ seenOrRecord(fromAddress: string, messageId: string, signedAt: number): Promise<boolean>;
809
+ /** When this cache file was first created, in ms since the epoch. */
810
+ createdAt(): Promise<number>;
811
+ /** True if an envelope signed at `signedAt` (ms) predates this cache. */
812
+ signedBeforeCache(signedAt: number): Promise<boolean>;
813
+ /** Number of ids currently held on disk. */
814
+ count(): Promise<number>;
815
+ /** Close the file; the next call opens it again. */
816
+ close(): Promise<void>;
817
+ private _open;
818
+ private _connect;
819
+ private _purge;
820
+ private _countRows;
821
+ }
822
+
734
823
  /**
735
824
  * Agent class -- the primary SDK interface (SDK-01).
736
825
  *
@@ -756,6 +845,23 @@ declare class Agent {
756
845
  readonly _contactBook: ContactBook;
757
846
  /** @internal */
758
847
  readonly _handshake: HandshakeManager;
848
+ /**
849
+ * @internal Wall clock in ms since the epoch, used by the inbound
850
+ * freshness/expiry checks and the replay cache. Replaceable in tests.
851
+ */
852
+ _clock: () => number;
853
+ /**
854
+ * @internal Inbound replay cache, keyed by (from address, message id).
855
+ * Kept on disk under the data dir, so it outlives restarts and is shared by
856
+ * every process using it; capacity from UAM_REPLAY_CACHE_CAPACITY.
857
+ */
858
+ readonly _replayCache: PersistentReplayCache;
859
+ /** Whether our relay has said it writes relay_meta. */
860
+ private _relayStamps;
861
+ /** Until when, by _monotonic, to take the relay's last no as still true. */
862
+ private _relayNoUntil;
863
+ /** @internal Monotonic clock in ms. Replaceable in tests. */
864
+ _monotonic: () => number;
759
865
  /**
760
866
  * Create an Agent. No I/O happens here -- call connect() to initialize.
761
867
  */
@@ -801,7 +907,10 @@ declare class Agent {
801
907
  * Retrieve, decrypt, and verify pending messages.
802
908
  *
803
909
  * Returns a list of ReceivedMessage data objects.
804
- * Silently drops messages with invalid signatures or decryption errors.
910
+ * Drops messages with invalid signatures or decryption errors. A message
911
+ * that fails processing for any other reason (malformed envelope, bad
912
+ * handshake payload, ...) is logged and dropped; it never aborts the
913
+ * rest of the batch.
805
914
  */
806
915
  inbox(limit?: number): Promise<ReceivedMessage[]>;
807
916
  /** List pending handshake requests. */
@@ -857,6 +966,10 @@ declare class Agent {
857
966
  /**
858
967
  * Resolve a recipient's public key, checking contact book first.
859
968
  *
969
+ * Pure lookup: never writes to the contact book. The contact row is
970
+ * created by _initiateHandshake (as "handshake-sent") once the request
971
+ * has gone out, giving the lifecycle no row -> handshake-sent -> pinned.
972
+ *
860
973
  * TOFU-01: Known contacts always use stored key (zero network I/O).
861
974
  * TOFU-03: Race-condition double-check after network resolve.
862
975
  * Raises KeyPinningError if a pinned contact's key doesn't match.
@@ -869,6 +982,26 @@ declare class Agent {
869
982
  * Returns null for invalid messages or handshake protocol messages.
870
983
  */
871
984
  private _processInbound;
985
+ /**
986
+ * Recipient, freshness, expiry and replay checks for a signature-verified
987
+ * inbound envelope. Returns false (and logs) when it must be dropped.
988
+ * Matches the Python SDK's _process_inbound checks.
989
+ */
990
+ private _passesInboundChecks;
991
+ /** True if a message too old to be live is stored mail, not a replay. */
992
+ private _wasStored;
993
+ /**
994
+ * True if our relay writes relay_meta, so a sender can't have.
995
+ *
996
+ * Asked only when a stale message carries a stamp. A yes is remembered for
997
+ * good, a no for RELAY_NO_MEMORY_MS: a relay can be upgraded while this
998
+ * agent runs. A failed lookup isn't remembered, because the relay has
999
+ * already marked this mail delivered and the next message should get a
1000
+ * real answer.
1001
+ */
1002
+ private _relayStampsMail;
1003
+ /** The relay's features; a body that doesn't list any is an empty list. */
1004
+ private _fetchRelayFeatures;
872
1005
  /**
873
1006
  * Sweep expired pending handshakes and send receipt.failed (HAND-03).
874
1007
  */
@@ -883,10 +1016,80 @@ declare class Agent {
883
1016
  /**
884
1017
  * Register with the relay server.
885
1018
  *
886
- * Calls POST /api/v1/register with agent name and public key.
887
- * Stores the returned API key on disk.
1019
+ * Calls POST /api/v1/register with agent name and public key. If the
1020
+ * address exists (409), repeats it with a proof that this agent holds the
1021
+ * key. Stores the returned API key on disk.
888
1022
  */
889
1023
  private _registerWithRelay;
1024
+ /** This agent's address on the relay, for signing a proof. */
1025
+ private _addressOnRelay;
1026
+ /**
1027
+ * True if the relay's URL fits `domain`, so a proof can be signed for it.
1028
+ *
1029
+ * A proof names an address on one relay, so it is only signed for a domain
1030
+ * this URL belongs to. A relay on this machine may use any domain.
1031
+ */
1032
+ private _relayMayUse;
1033
+ private _postRegistration;
1034
+ /**
1035
+ * Sign a statement that this agent holds the key for `address`.
1036
+ *
1037
+ * The signed string must stay byte-identical to the Python SDK's.
1038
+ */
1039
+ private _registrationProof;
1040
+ private _ownershipNotProven;
1041
+ /** Remember `address` for this relay, if the key folder allows it. */
1042
+ private _saveAddress;
1043
+ /** Saving is best effort: a key folder we can't write doesn't stop us. */
1044
+ private _tryToSave;
1045
+ /** Take `address` as ours; its domain is this relay's real domain. */
1046
+ private _useAddress;
1047
+ /**
1048
+ * The address the stored token belongs to.
1049
+ *
1050
+ * An explicit domain settles it. Otherwise the relay chooses the domain,
1051
+ * and it need not be the relay's hostname: use the address saved for this
1052
+ * relay, or ask the relay once and keep the answer.
1053
+ */
1054
+ private _storedAddress;
1055
+ /** Ask the relay for its domain; null if it cannot say. */
1056
+ private _fetchRelayDomain;
890
1057
  }
891
1058
 
892
- export { Agent, type ReceivedMessage, index as protocol };
1059
+ /**
1060
+ * Receiver-side webhook signature verification (HOOK-03).
1061
+ *
1062
+ * Small utility for webhook receivers to verify that incoming webhook
1063
+ * payloads were signed by the UAM relay. Uses HMAC-SHA256 with the
1064
+ * agent's webhook secret, which the relay returns when the webhook is set
1065
+ * (PUT /api/v1/agents/{address}/webhook). Setting the same URL again
1066
+ * returns it again.
1067
+ *
1068
+ * Matches Python webhook_verify.py API surface.
1069
+ *
1070
+ * Usage:
1071
+ *
1072
+ * import { verifyWebhookSignature } from "@youam/sdk";
1073
+ *
1074
+ * const payload = Buffer.from(requestBody);
1075
+ * const signature = request.headers["x-uam-signature"];
1076
+ * const secret = "your-webhook-secret";
1077
+ *
1078
+ * if (verifyWebhookSignature(payload, signature, secret)) {
1079
+ * // payload is authentic
1080
+ * }
1081
+ */
1082
+ /**
1083
+ * Verify an HMAC-SHA256 webhook signature.
1084
+ *
1085
+ * @param payload - Raw request body bytes
1086
+ * @param signatureHeader - "sha256=<hex>" format (X-UAM-Signature header)
1087
+ * @param secret - The agent's webhook secret
1088
+ * @returns `true` if valid, `false` otherwise.
1089
+ *
1090
+ * Uses `crypto.timingSafeEqual()` for constant-time comparison
1091
+ * to prevent timing attacks.
1092
+ */
1093
+ declare function verifyWebhookSignature(payload: Buffer | Uint8Array, signatureHeader: string, secret: string): boolean;
1094
+
1095
+ export { Agent, ContactBook, type ReceivedMessage, index as protocol, verifyWebhookSignature };