@tavrn/e2ee 0.0.0-stage → 0.2.2

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.
@@ -0,0 +1,216 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * The account's current identity key and its rotation with continuity (ENCRYPTION.md §2.1, §9a). Generation 0 is
4
+ * the key derived from the master secret; after a rotation the current key is a random one, kept in the key store
5
+ * as {@link IDENTITY_SEED} and on the home node wrapped under the master secret (`identity_blob`). Every device of
6
+ * the account follows the published key when the keys document's continuity chain leads to it from the derived key.
7
+ */
8
+ import { IdentityKey, continuityKeys, continuityReaches, signContinuity, unwrapIdentityKey, verifyDeviceAttestation, wrapIdentityKey } from "@tavrn/crypto";
9
+ import { TavrnApiError, b64urlDecode, b64urlEncode, type TavrnApi } from "@tavrn/sdk";
10
+ import { E2eeError } from "./errors";
11
+ import { withMasterSecret, type KeyStore } from "./keystore";
12
+ import { ensureCrypto } from "./wasm";
13
+
14
+ /** Name of the rotated identity key (32-byte seed) in the key store; absent at generation 0. */
15
+ export const IDENTITY_SEED = "identity-seed";
16
+
17
+ /** A continuity statement as the keys document carries it (base64url keys and signatures). */
18
+ export interface ContinuityStatement {
19
+ old_key: string;
20
+ new_key: string;
21
+ at: string;
22
+ sig_old: string;
23
+ sig_new: string;
24
+ }
25
+
26
+ /** The parts of a keys document (`GET /users/{username}/keys`) the identity checks use. */
27
+ export interface IdentityDocument {
28
+ identity_public_key?: string | null;
29
+ continuity?: ContinuityStatement[] | null;
30
+ }
31
+
32
+ /**
33
+ * Longest chain that is followed, as in the crypto core: a longer one (only a hostile node serves one) is not even
34
+ * serialized.
35
+ */
36
+ export const MAX_CHAIN = 10_000;
37
+
38
+ /** Is `to` reachable from `from` through valid, consecutive statements of `chain`? `true` for `from === to`. */
39
+ export function chainReaches(chain: readonly ContinuityStatement[] | null | undefined, from: string, to: string): boolean {
40
+ if (from === to) return true;
41
+ if (!chain?.length || chain.length > MAX_CHAIN) return false;
42
+ return continuityReaches(JSON.stringify(chain), from, to);
43
+ }
44
+
45
+ /**
46
+ * The keys that lead to `current` through valid statements of `chain`, `current` included: attestations made under
47
+ * them (retired devices, ENCRYPTION.md §3.3) stay trustworthy after a rotation. One call into the crypto core, which
48
+ * verifies every statement at most once.
49
+ */
50
+ export function chainKeys(chain: readonly ContinuityStatement[] | null | undefined, current: string): Set<string> {
51
+ if (!chain?.length || chain.length > MAX_CHAIN) return new Set([current]);
52
+ return new Set(continuityKeys(JSON.stringify(chain), current));
53
+ }
54
+
55
+ /** RFC 3339 time without fractions (the form the node checks against its clock). */
56
+ export function rfc3339(ms: number): string {
57
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
58
+ }
59
+
60
+ /** Runs `fn` with the store's current identity key (the rotated one if present, else the derived one), freed afterwards. */
61
+ export async function withIdentityKey<T>(store: KeyStore, fn: (key: IdentityKey) => T): Promise<T> {
62
+ await ensureCrypto();
63
+ const seed = await store.getSecret(IDENTITY_SEED);
64
+ let key: IdentityKey;
65
+ if (seed) {
66
+ try {
67
+ key = IdentityKey.fromBytes(seed);
68
+ } finally {
69
+ seed.fill(0);
70
+ }
71
+ } else {
72
+ key = withMasterSecret(store, (m) => IdentityKey.fromMaster(m));
73
+ }
74
+ try {
75
+ return fn(key);
76
+ } finally {
77
+ key.free();
78
+ }
79
+ }
80
+
81
+ /** The current identity public key of the open account (base64url). */
82
+ export function ownIdentityPublicKey(store: KeyStore): Promise<string> {
83
+ return withIdentityKey(store, (k) => b64urlEncode(k.publicKey()));
84
+ }
85
+
86
+ /** The identity public key derived from the master secret (generation 0, base64url). */
87
+ export function derivedIdentityKey(store: KeyStore): string {
88
+ return withMasterSecret(store, (m) => b64urlEncode(m.identityPublicKey()));
89
+ }
90
+
91
+ /**
92
+ * The rotated identity key (seed) that a master secret leads to for the published key, or null at generation 0
93
+ * (ENCRYPTION.md §2.4 client rules): the derived key equals the published key, or the chain reaches the published key
94
+ * from the derived one and the blob unwraps with the master secret to exactly that key. Anything else is
95
+ * `identity_mismatch`. The caller zeroes the returned seed.
96
+ */
97
+ export async function resolveIdentity(
98
+ api: Pick<TavrnApi, "keys">,
99
+ doc: IdentityDocument,
100
+ derived: string,
101
+ unwrap: (blob: Uint8Array) => IdentityKey,
102
+ ): Promise<Uint8Array | null> {
103
+ const published = doc.identity_public_key ?? null;
104
+ if (!published || published === derived) return null;
105
+ if (!chainReaches(doc.continuity, derived, published)) throw new E2eeError("identity_mismatch");
106
+ let blob: string;
107
+ try {
108
+ blob = (await api.keys.identityBlob()).identity_blob;
109
+ } catch (e) {
110
+ if (e instanceof TavrnApiError && e.status === 404) throw new E2eeError("identity_mismatch");
111
+ throw e;
112
+ }
113
+ let key: IdentityKey;
114
+ try {
115
+ key = unwrap(b64urlDecode(blob));
116
+ } catch {
117
+ throw new E2eeError("identity_mismatch");
118
+ }
119
+ try {
120
+ if (b64urlEncode(key.publicKey()) !== published) throw new E2eeError("identity_mismatch");
121
+ return key.toBytes();
122
+ } finally {
123
+ key.free();
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Brings the open store's identity key in line with the published one (another device rotated, ENCRYPTION.md §9a):
129
+ * `current` when nothing changed, `rotated` when the new key was taken over from the blob. Throws
130
+ * {@link E2eeError} `identity_mismatch` when the published key is not reachable from this master secret.
131
+ */
132
+ export async function syncIdentity(
133
+ api: Pick<TavrnApi, "keys">,
134
+ store: KeyStore,
135
+ username: string,
136
+ address: string,
137
+ doc?: IdentityDocument,
138
+ ): Promise<"current" | "rotated"> {
139
+ await ensureCrypto();
140
+ if (!store.isUnlocked()) throw new E2eeError("locked");
141
+ const keys = doc ?? (await api.keys.get(username));
142
+ const published = keys.identity_public_key ?? null;
143
+ const own = await ownIdentityPublicKey(store);
144
+ if (!published || published === own) return "current";
145
+ const derived = derivedIdentityKey(store);
146
+ // Forward only: a device that already follows a rotated key accepts a newer key only when the chain leads to it
147
+ // from that key. Otherwise the home node could roll the device back to an older generation (whose key may be the
148
+ // compromised one the rotation retired) by serving the old chain and blob again.
149
+ if (own !== derived && !chainReaches(keys.continuity, own, published)) throw new E2eeError("identity_mismatch");
150
+ const seed = await resolveIdentity(api, keys, derived, (blob) => withMasterSecret(store, (m) => unwrapIdentityKey(m, blob, address)));
151
+ if (!seed) {
152
+ await store.deleteSecret(IDENTITY_SEED);
153
+ return "rotated";
154
+ }
155
+ try {
156
+ await store.putSecret(IDENTITY_SEED, seed);
157
+ } finally {
158
+ seed.fill(0);
159
+ }
160
+ return "rotated";
161
+ }
162
+
163
+ /** What a rotation needs from the API. */
164
+ export interface RotationContext {
165
+ api: Pick<TavrnApi, "keys">;
166
+ username: string;
167
+ address: string;
168
+ store: KeyStore;
169
+ /** Clock (tests). */
170
+ now?: () => number;
171
+ }
172
+
173
+ /**
174
+ * Rotates the identity key with continuity (ENCRYPTION.md §9a): new random key, statement signed by the current and
175
+ * the new key, the new key wrapped under the master secret, every device of the keys document attested again. The
176
+ * node asks for a reauth token (`auth.reauth_required`; the web client confirms and repeats). Refuses with
177
+ * `rotation_devices` when a listed device does not verify under the current key (the document and the local state
178
+ * disagree) and with `identity_mismatch` when this device is not on the published key.
179
+ */
180
+ export async function rotateIdentity(ctx: RotationContext): Promise<{ identity_public_key: string; identity_generation: number }> {
181
+ await ensureCrypto();
182
+ const { store, address } = ctx;
183
+ if (!store.isUnlocked()) throw new E2eeError("locked");
184
+ const doc = await ctx.api.keys.get(ctx.username);
185
+ const current = await ownIdentityPublicKey(store);
186
+ if (doc.identity_public_key !== current) throw new E2eeError("identity_mismatch");
187
+ const currentBytes = b64urlDecode(current);
188
+ const devices = doc.devices.map((d) => {
189
+ const key = b64urlDecode(d.device_public_key);
190
+ if (!verifyDeviceAttestation(address, d.device_id, key, b64urlDecode(d.identity_signature), currentBytes)) throw new E2eeError("rotation_devices");
191
+ return { id: d.device_id, key };
192
+ });
193
+ const fresh = IdentityKey.generate();
194
+ try {
195
+ const at = rfc3339(ctx.now?.() ?? Date.now());
196
+ const continuity = JSON.parse(await withIdentityKey(store, (old) => signContinuity(old, fresh, at))) as ContinuityStatement;
197
+ const blob = withMasterSecret(store, (m) => wrapIdentityKey(m, fresh, address));
198
+ const attestations = devices.map((d) => ({ device_id: d.id, identity_signature: b64urlEncode(fresh.attestDevice(address, d.id, d.key)) }));
199
+ const res = await ctx.api.keys.rotateIdentity({
200
+ new_identity_public_key: b64urlEncode(fresh.publicKey()),
201
+ continuity,
202
+ identity_blob: b64urlEncode(blob),
203
+ attestations,
204
+ });
205
+ // If this fails, the next start takes the key from the blob (syncIdentity).
206
+ const seed = fresh.toBytes();
207
+ try {
208
+ await store.putSecret(IDENTITY_SEED, seed);
209
+ } finally {
210
+ seed.fill(0);
211
+ }
212
+ return res;
213
+ } finally {
214
+ fresh.free();
215
+ }
216
+ }
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * @tavrn/e2ee – the client side of tavrn's end-to-end encryption, shared by the web client and bots:
4
+ * the MLS session manager, the content and attachment codecs, plaintext signatures, the account flows
5
+ * (device attestation, KeyPackages, secret storage, recovery key) and the {@link KeyStore} abstraction.
6
+ * Environment-neutral (browsers and Node.js); the file store for Node.js is exported from
7
+ * `@tavrn/e2ee/node`.
8
+ */
9
+ export { ensureCrypto } from "./wasm";
10
+ export {
11
+ LocalKeyMismatchError,
12
+ localAad,
13
+ localStorageKey,
14
+ readJsonSecret,
15
+ withBackupKey,
16
+ withMasterSecret,
17
+ writeJsonSecret,
18
+ type CryptoStatus,
19
+ type KeyStore,
20
+ } from "./keystore";
21
+ export { MemoryKeyStore } from "./storage/memory";
22
+ export {
23
+ CLIENT_STATE,
24
+ E2eeError,
25
+ KEY_PACKAGE_BATCH,
26
+ KEY_PACKAGE_MIN,
27
+ addPasskeySecret,
28
+ deviceLoginProof,
29
+ ensureDevice,
30
+ forgetIdentityKeepDevice,
31
+ loadClient,
32
+ localDeviceLost,
33
+ localIdentityKey,
34
+ localStatus,
35
+ passkeyEntryIds,
36
+ regenerateRecoveryKey,
37
+ resetIdentity,
38
+ saveClient,
39
+ setupAccount,
40
+ unlockWithPasskeyPrf,
41
+ unlockWithRecoveryKey,
42
+ wipeLocal,
43
+ type DeviceProof as LocalDeviceProof,
44
+ type E2eeContext,
45
+ type E2eeErrorCode,
46
+ } from "./account";
47
+ export * from "./mls";
48
+ export * from "./cleanup";
49
+ export * from "./trust";
50
+ export { META, PINS, VERIFIED, openMlsManager, reloadOwnIdentity, type OpenMlsOptions } from "./session";
51
+ export { TAB_TIMEOUT_MS, TabCoordinator, type TabChannel, type TabHost, type TabLockManager, type TabOptions } from "./tabs";
52
+ export * from "./content";
53
+ export * from "./embeds";
54
+ export * from "./sealed";
55
+ export * from "./commands";
56
+ export * from "./components";
57
+ export * from "./plaintext";
58
+ export * from "./history";
59
+ export * from "./files";
60
+ export * from "./poll";
61
+ export * from "./search";
62
+ export {
63
+ IDENTITY_SEED,
64
+ chainKeys,
65
+ chainReaches,
66
+ derivedIdentityKey,
67
+ ownIdentityPublicKey,
68
+ resolveIdentity,
69
+ rotateIdentity,
70
+ syncIdentity,
71
+ withIdentityKey,
72
+ type ContinuityStatement,
73
+ type IdentityDocument,
74
+ type RotationContext,
75
+ } from "./identity";
@@ -0,0 +1,126 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Local key storage (ENCRYPTION.md §2.1 "local storage key"). A `KeyStore` keeps, per account, the
4
+ * device id, the 32-byte master secret and named secrets (the exported MLS client state, pins, MLS
5
+ * bookkeeping, verified contacts), everything encrypted at rest under
6
+ * `local_key = MasterSecret.localStorageKey(deviceId)`.
7
+ *
8
+ * Implementations: the IndexedDB store of the web client (tavrn-web `src/crypto/store.ts`, with "lock on
9
+ * close"), {@link MemoryKeyStore} (tests, short-lived tools) and the file store for Node.js
10
+ * (`FileKeyStore` from `@tavrn/e2ee/node`, the state directory of a bot).
11
+ *
12
+ * At most one account is open ("unlocked") at a time; the secret accessors act on that account and
13
+ * throw while the store is locked.
14
+ */
15
+ import { MasterSecret } from "@tavrn/crypto";
16
+ import { E2eeError } from "./errors";
17
+ import { ensureCrypto } from "./wasm";
18
+
19
+ export type CryptoStatus = "none" | "locked" | "unlocked";
20
+
21
+ export interface KeyStore {
22
+ /** Whether this store holds the account's master secret, and whether it is readable right now. */
23
+ status(account: string): Promise<CryptoStatus>;
24
+ /** Device id the account's local data belongs to, or null if there is none. */
25
+ deviceIdOf(account: string): Promise<string | null>;
26
+ /**
27
+ * Opens the store without user interaction (a persisted local key); false if that is not possible
28
+ * (no data, "lock on close", or a store that always needs the master secret).
29
+ */
30
+ resume(account: string): Promise<boolean>;
31
+ /**
32
+ * Stores a master secret for this device and unlocks. Replaces earlier data of the account, including
33
+ * its named secrets.
34
+ */
35
+ saveMaster(account: string, deviceId: string, master: Uint8Array): Promise<void>;
36
+ /** Unlocks existing data with the master secret; throws {@link LocalKeyMismatchError} if it does not open it. */
37
+ unlockWithMaster(account: string, master: Uint8Array): Promise<void>;
38
+ isUnlocked(account?: string): boolean;
39
+ /** Forgets the key and the master secret in memory (data stays stored). */
40
+ lock(): void;
41
+ /** Deletes all local data of the account and locks if it was open. */
42
+ wipe(account: string): Promise<void>;
43
+ /** Account address of the open store, or null. */
44
+ account(): string | null;
45
+ /** Device id of the open account. */
46
+ deviceId(): string;
47
+ /** A copy of the 32-byte master secret; the caller should `fill(0)` it after use. */
48
+ masterSecret(): Uint8Array;
49
+ putSecret(name: string, value: Uint8Array): Promise<void>;
50
+ getSecret(name: string): Promise<Uint8Array | null>;
51
+ deleteSecret(name: string): Promise<void>;
52
+ }
53
+
54
+ /** Thrown when a master secret does not open this device's local data (stale entry or reset elsewhere). */
55
+ export class LocalKeyMismatchError extends Error {
56
+ constructor() {
57
+ super("The master secret does not match the local data of this device");
58
+ this.name = "local_key_mismatch";
59
+ }
60
+ }
61
+
62
+ /** Additional data of every sealed value: binds it to the account and the entry name. */
63
+ export function localAad(account: string, name: string): Uint8Array<ArrayBuffer> {
64
+ return new TextEncoder().encode(`tavrn local v1\u0000${account}\u0000${name}`);
65
+ }
66
+
67
+ /** The raw 32-byte local storage key of `deviceId` (ENCRYPTION.md §2.1); the caller zeroes it after use. */
68
+ export async function localStorageKey(master: Uint8Array, deviceId: string): Promise<Uint8Array> {
69
+ await ensureCrypto();
70
+ const ms = MasterSecret.fromBytes(master);
71
+ try {
72
+ return ms.localStorageKey(deviceId);
73
+ } finally {
74
+ ms.free();
75
+ }
76
+ }
77
+
78
+ /** Runs `fn` with the store's master secret as a WASM object, freed afterwards. Requires an unlocked store. */
79
+ export function withMasterSecret<T>(store: KeyStore, fn: (m: MasterSecret) => T): T {
80
+ const bytes = store.masterSecret();
81
+ const master = MasterSecret.fromBytes(bytes);
82
+ bytes.fill(0);
83
+ try {
84
+ return fn(master);
85
+ } finally {
86
+ master.free();
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Runs `fn` with a fresh copy of the backup key (ENCRYPTION.md §2.1), zeroed afterwards; null while the
92
+ * store is locked. Used for data the user keeps encrypted on the home node (whiteboard scenes).
93
+ */
94
+ export function withBackupKey<T>(store: KeyStore, fn: (key: Uint8Array) => T): T | null {
95
+ if (!store.isUnlocked()) return null;
96
+ const key = withMasterSecret(store, (m) => m.backupKey());
97
+ try {
98
+ return fn(key);
99
+ } finally {
100
+ key.fill(0);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Reads a named secret holding JSON; `fallback` if it is missing or unreadable. With `strict`, a secret that exists
106
+ * but does not parse throws `E2eeError("state_unreadable")` instead (and stays stored): for data whose loss must not
107
+ * go unnoticed, such as the key pins (a silent reset would trust every changed key again).
108
+ */
109
+ export async function readJsonSecret<T>(store: KeyStore, name: string, fallback: T, opts: { strict?: boolean } = {}): Promise<T> {
110
+ const bytes = await store.getSecret(name);
111
+ if (!bytes) return fallback;
112
+ try {
113
+ return JSON.parse(new TextDecoder().decode(bytes)) as T;
114
+ } catch {
115
+ if (opts.strict) throw new E2eeError("state_unreadable");
116
+ return fallback;
117
+ } finally {
118
+ bytes.fill(0);
119
+ }
120
+ }
121
+
122
+ /** Stores a value as a JSON secret; does nothing while the store is locked (e.g. after sign-out). */
123
+ export async function writeJsonSecret(store: KeyStore, name: string, value: unknown): Promise<void> {
124
+ if (!store.isUnlocked()) return;
125
+ await store.putSecret(name, new TextEncoder().encode(JSON.stringify(value)));
126
+ }
package/src/lru.ts ADDED
@@ -0,0 +1,113 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /** Small bounded caches of the session manager (a session may run for weeks: a bot, a tab that stays open). */
3
+
4
+ /**
5
+ * A `Map` with at most `limit` entries: a read moves its entry to the newest end, a write beyond the limit evicts the
6
+ * least recently used entry (`Map` keeps insertion order).
7
+ */
8
+ export class LruMap<K, V> {
9
+ private readonly map = new Map<K, V>();
10
+
11
+ constructor(readonly limit: number) {
12
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError("LruMap: limit must be a positive integer");
13
+ }
14
+
15
+ get size(): number {
16
+ return this.map.size;
17
+ }
18
+
19
+ /** The value of `key`, marked as recently used. */
20
+ get(key: K): V | undefined {
21
+ const value = this.map.get(key);
22
+ if (value === undefined) return undefined;
23
+ this.map.delete(key);
24
+ this.map.set(key, value);
25
+ return value;
26
+ }
27
+
28
+ /** The value of `key` without changing its age. */
29
+ peek(key: K): V | undefined {
30
+ return this.map.get(key);
31
+ }
32
+
33
+ has(key: K): boolean {
34
+ return this.map.has(key);
35
+ }
36
+
37
+ set(key: K, value: V): this {
38
+ this.map.delete(key);
39
+ this.map.set(key, value);
40
+ if (this.map.size > this.limit) {
41
+ const oldest = this.map.keys().next();
42
+ if (!oldest.done) this.map.delete(oldest.value);
43
+ }
44
+ return this;
45
+ }
46
+
47
+ delete(key: K): boolean {
48
+ return this.map.delete(key);
49
+ }
50
+
51
+ clear(): void {
52
+ this.map.clear();
53
+ }
54
+
55
+ /** Deletes every entry `drop` selects. */
56
+ deleteWhere(drop: (value: V, key: K) => boolean): void {
57
+ for (const [k, v] of [...this.map]) if (drop(v, k)) this.map.delete(k);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * A 53-bit hash of a string (cyrb53, public domain), as a fingerprint of a ciphertext in a decrypt cache entry: the
63
+ * cache key names the message and the sender fields its ciphertext is bound to (author, device, epoch), the
64
+ * fingerprint tells a changed ciphertext of that message apart without keeping it. NOT a cryptographic hash: second
65
+ * preimages are cheap to compute, so it is only used where a collision can at most answer with the earlier, verified
66
+ * result of the same message from the same sender, and never to decide whether a signature or a plaintext is valid.
67
+ */
68
+ export function fingerprint(s: string, seed = 0): string {
69
+ let h1 = 0xdeadbeef ^ seed;
70
+ let h2 = 0x41c6ce57 ^ seed;
71
+ for (let i = 0; i < s.length; i++) {
72
+ const c = s.charCodeAt(i);
73
+ h1 = Math.imul(h1 ^ c, 2654435761);
74
+ h2 = Math.imul(h2 ^ c, 1597334677);
75
+ }
76
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
77
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
78
+ return `${s.length.toString(36)}.${(4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)}`;
79
+ }
80
+
81
+ /**
82
+ * Nonces seen in the last `keepMs` (replay windows of signed arguments and component values): each one counts until
83
+ * `keepMs` after it was seen. Expired nonces are swept from the oldest end (insertion order follows the clock), so a
84
+ * check costs O(1) amortized instead of a scan of the whole window.
85
+ */
86
+ export class NonceWindow {
87
+ private readonly seen = new Map<string, number>();
88
+
89
+ constructor(private readonly keepMs: number) {}
90
+
91
+ get size(): number {
92
+ return this.seen.size;
93
+ }
94
+
95
+ /** Whether `nonce` was remembered within the window at `now`. */
96
+ has(nonce: string, now: number): boolean {
97
+ this.sweep(now);
98
+ const at = this.seen.get(nonce);
99
+ return at !== undefined && now - at <= this.keepMs;
100
+ }
101
+
102
+ remember(nonce: string, now: number): void {
103
+ this.seen.delete(nonce);
104
+ this.seen.set(nonce, now);
105
+ }
106
+
107
+ private sweep(now: number): void {
108
+ for (const [nonce, at] of this.seen) {
109
+ if (now - at <= this.keepMs) break;
110
+ this.seen.delete(nonce);
111
+ }
112
+ }
113
+ }