Skip to content

Commit 949f1ea

Browse files
committed
feat(messaging-groups): add TTL expiry to DEK cache matching session key lifetime
1 parent 2c9298e commit 949f1ea

5 files changed

Lines changed: 225 additions & 10 deletions

File tree

ts-sdks/packages/messaging-groups/src/encryption/envelope-encryption.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import type { SealClient, SessionKey } from '@mysten/seal';
55
import { EncryptedObject, NoAccessError } from '@mysten/seal';
66
import { bcs } from '@mysten/sui/bcs';
7-
import type { ClientCache, ClientWithCoreApi } from '@mysten/sui/client';
7+
import { ClientCache } from '@mysten/sui/client';
8+
import type { ClientWithCoreApi } from '@mysten/sui/client';
89
import { Transaction } from '@mysten/sui/transactions';
910
import { fromHex, isValidSuiAddress } from '@mysten/sui/utils';
1011

@@ -17,6 +18,7 @@ import { getDefaultCryptoPrimitives } from './crypto-primitives.js';
1718
import { DEKManager, NONCE_LENGTH, type GeneratedDEK } from './dek-manager.js';
1819
import { DefaultSealPolicy, type SealPolicy } from './seal-policy.js';
1920
import { SessionKeyManager } from './session-key-manager.js';
21+
import { TtlMap } from './ttl-map.js';
2022

2123
// === AAD (Additional Authenticated Data) ===
2224

@@ -164,18 +166,20 @@ export class EnvelopeEncryption<TApproveContext = void> {
164166
config.versionId,
165167
)) as SealPolicy<TApproveContext>;
166168
this.#crypto = config.encryption.cryptoPrimitives ?? getDefaultCryptoPrimitives();
167-
this.#dekCache = config.suiClient.cache.scope('dek');
169+
this.#sessionKeyManager = new SessionKeyManager({
170+
sessionKeyConfig: config.encryption.sessionKey,
171+
packageId: config.originalPackageId,
172+
suiClient: config.suiClient,
173+
});
174+
this.#dekCache = new ClientCache({
175+
cache: new TtlMap(this.#sessionKeyManager.ttlMs),
176+
});
168177
this.#dekManager = new DEKManager({
169178
sealClient: config.sealClient,
170179
sealPolicy: this.#sealPolicy,
171180
cryptoPrimitives: config.encryption.cryptoPrimitives,
172181
defaultThreshold: config.encryption.sealThreshold,
173182
});
174-
this.#sessionKeyManager = new SessionKeyManager({
175-
sessionKeyConfig: config.encryption.sessionKey,
176-
packageId: config.originalPackageId,
177-
suiClient: config.suiClient,
178-
});
179183
}
180184

181185
// === High-Level API ===

ts-sdks/packages/messaging-groups/src/encryption/session-key-manager.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import type { ClientWithCoreApi } from '@mysten/sui/client';
77

88
import type { SessionKeyConfig } from '../types.js';
99

10+
/** Default session key TTL in minutes. */
11+
export const DEFAULT_SESSION_KEY_TTL_MIN = 10;
12+
1013
export interface SessionKeyManagerConfig {
1114
/** Configuration for how session keys are obtained (tier 1/2/3). */
1215
sessionKeyConfig: SessionKeyConfig;
@@ -35,6 +38,16 @@ export class SessionKeyManager {
3538
this.#refreshBufferMs = this.#resolveRefreshBuffer();
3639
}
3740

41+
/** The configured session key TTL in milliseconds. */
42+
get ttlMs(): number {
43+
const skConfig = this.#config.sessionKeyConfig;
44+
const ttlMin =
45+
'ttlMin' in skConfig
46+
? (skConfig.ttlMin ?? DEFAULT_SESSION_KEY_TTL_MIN)
47+
: DEFAULT_SESSION_KEY_TTL_MIN;
48+
return ttlMin * 60_000;
49+
}
50+
3851
/** Returns a valid, non-expired session key — creating or refreshing as needed. */
3952
async getSessionKey(): Promise<SessionKey> {
4053
if (this.#sessionKey && !this.#needsRefresh()) {
@@ -87,7 +100,7 @@ export class SessionKeyManager {
87100
address: skConfig.signer.toSuiAddress(),
88101
packageId: this.#config.packageId,
89102
mvrName: skConfig.mvrName,
90-
ttlMin: skConfig.ttlMin ?? 10,
103+
ttlMin: skConfig.ttlMin ?? DEFAULT_SESSION_KEY_TTL_MIN,
91104
signer: skConfig.signer,
92105
suiClient: this.#config.suiClient,
93106
});
@@ -100,7 +113,7 @@ export class SessionKeyManager {
100113
address: skConfig.address,
101114
packageId: this.#config.packageId,
102115
mvrName: skConfig.mvrName,
103-
ttlMin: skConfig.ttlMin ?? 10,
116+
ttlMin: skConfig.ttlMin ?? DEFAULT_SESSION_KEY_TTL_MIN,
104117
suiClient: this.#config.suiClient,
105118
});
106119
const message = sessionKey.getPersonalMessage();
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
interface TtlEntry {
5+
value: unknown;
6+
expiresAt: number;
7+
}
8+
9+
/**
10+
* A `Map<string, unknown>`-compatible class that expires entries after a TTL.
11+
*
12+
* Designed to be injected into `ClientCache` via its `cache` constructor option,
13+
* making the cache TTL-aware without modifying `ClientCache` itself.
14+
*
15+
* Entries are lazily evicted: stale entries are removed on `get()` / `has()`.
16+
*/
17+
export class TtlMap extends Map<string, unknown> {
18+
readonly #ttlMs: number;
19+
20+
constructor(ttlMs: number) {
21+
super();
22+
this.#ttlMs = ttlMs;
23+
}
24+
25+
override set(key: string, value: unknown): this {
26+
super.set(key, { value, expiresAt: Date.now() + this.#ttlMs } satisfies TtlEntry);
27+
return this;
28+
}
29+
30+
override get(key: string): unknown {
31+
const entry = super.get(key) as TtlEntry | undefined;
32+
if (!entry) return undefined;
33+
if (Date.now() >= entry.expiresAt) {
34+
this.delete(key);
35+
return undefined;
36+
}
37+
return entry.value;
38+
}
39+
40+
override has(key: string): boolean {
41+
return this.get(key) !== undefined;
42+
}
43+
}

ts-sdks/packages/messaging-groups/test/unit/envelope-encryption.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { SessionKey } from '@mysten/seal';
55
import type { SealCompatibleClient } from '@mysten/seal';
66
import { ClientCache, type ClientWithCoreApi } from '@mysten/sui/client';
77
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
8-
import { describe, expect, it } from 'vitest';
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
99

1010
import type { MessagingGroupsView } from '../../src/view.js';
1111
import { MessagingGroupsDerive } from '../../src/derive.js';
@@ -320,4 +320,81 @@ describe('EnvelopeEncryption', () => {
320320
expect(Array.from(env0.ciphertext)).not.toEqual(Array.from(env1.ciphertext));
321321
});
322322
});
323+
324+
describe('cache TTL', () => {
325+
beforeEach(() => {
326+
vi.useFakeTimers();
327+
});
328+
329+
afterEach(() => {
330+
vi.useRealTimers();
331+
});
332+
333+
it('should serve cached DEK before TTL expires', async () => {
334+
const view = createMockView();
335+
const encryptedKeySpy = vi.fn();
336+
view.encryptedKey = encryptedKeySpy;
337+
338+
const ee = new EnvelopeEncryption({
339+
sealClient: createMockSealClient(),
340+
suiClient: createMockSuiClient(),
341+
view,
342+
derive: createMockDerive(),
343+
originalPackageId: MOCK_PACKAGE_ID,
344+
latestPackageId: MOCK_PACKAGE_ID,
345+
versionId: MOCK_VERSION_ID,
346+
encryption: {
347+
sessionKey: { getSessionKey: () => createTestSessionKey() },
348+
},
349+
});
350+
351+
const data = new TextEncoder().encode('hello');
352+
const { uuid } = await ee.generateGroupDEK();
353+
354+
// Encrypt immediately — cache is warm from generateGroupDEK
355+
await ee.encrypt({ uuid, keyVersion: 0n, data });
356+
expect(encryptedKeySpy).not.toHaveBeenCalled();
357+
358+
// Advance time but stay within TTL (default 10 min = 600_000ms)
359+
vi.advanceTimersByTime(300_000);
360+
361+
await ee.encrypt({ uuid, keyVersion: 0n, data });
362+
// Still no call to view.encryptedKey — cache hit
363+
expect(encryptedKeySpy).not.toHaveBeenCalled();
364+
});
365+
366+
it('should evict cached DEK after TTL expires and re-fetch', async () => {
367+
const view = createMockView();
368+
const encryptedKeySpy = vi.fn();
369+
view.encryptedKey = encryptedKeySpy;
370+
371+
const ee = new EnvelopeEncryption({
372+
sealClient: createMockSealClient(),
373+
suiClient: createMockSuiClient(),
374+
view,
375+
derive: createMockDerive(),
376+
originalPackageId: MOCK_PACKAGE_ID,
377+
latestPackageId: MOCK_PACKAGE_ID,
378+
versionId: MOCK_VERSION_ID,
379+
encryption: {
380+
sessionKey: { getSessionKey: () => createTestSessionKey() },
381+
},
382+
});
383+
384+
const data = new TextEncoder().encode('hello');
385+
const { uuid } = await ee.generateGroupDEK();
386+
387+
// Encrypt — cache hit, no view call
388+
await ee.encrypt({ uuid, keyVersion: 0n, data });
389+
expect(encryptedKeySpy).not.toHaveBeenCalled();
390+
391+
// Advance past the TTL (default 10 min for Tier 3)
392+
vi.advanceTimersByTime(600_001);
393+
394+
// Next encrypt triggers cache miss → calls view.encryptedKey.
395+
// The spy will throw (no real return value), proving the cache was evicted.
396+
await expect(ee.encrypt({ uuid, keyVersion: 0n, data })).rejects.toThrow();
397+
expect(encryptedKeySpy).toHaveBeenCalledTimes(1);
398+
});
399+
});
323400
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
import { TtlMap } from '../../src/encryption/ttl-map.js';
7+
8+
describe('TtlMap', () => {
9+
beforeEach(() => {
10+
vi.useFakeTimers();
11+
});
12+
13+
afterEach(() => {
14+
vi.useRealTimers();
15+
});
16+
17+
it('returns a stored value before TTL expires', () => {
18+
const map = new TtlMap(1000);
19+
map.set('key', 'value');
20+
21+
expect(map.get('key')).toBe('value');
22+
expect(map.has('key')).toBe(true);
23+
});
24+
25+
it('returns undefined after TTL expires', () => {
26+
const map = new TtlMap(1000);
27+
map.set('key', 'value');
28+
29+
vi.advanceTimersByTime(1000);
30+
31+
expect(map.get('key')).toBeUndefined();
32+
expect(map.has('key')).toBe(false);
33+
});
34+
35+
it('evicts only expired entries', () => {
36+
const map = new TtlMap(1000);
37+
map.set('early', 'a');
38+
39+
vi.advanceTimersByTime(500);
40+
map.set('late', 'b');
41+
42+
vi.advanceTimersByTime(500);
43+
44+
// 'early' was set 1000ms ago — expired
45+
expect(map.has('early')).toBe(false);
46+
// 'late' was set 500ms ago — still alive
47+
expect(map.get('late')).toBe('b');
48+
});
49+
50+
it('deletes expired entries lazily on get()', () => {
51+
const map = new TtlMap(1000);
52+
map.set('key', 'value');
53+
54+
vi.advanceTimersByTime(1000);
55+
map.get('key');
56+
57+
// Entry should be deleted from the underlying Map
58+
expect(map.size).toBe(0);
59+
});
60+
61+
it('refreshes TTL when re-setting the same key', () => {
62+
const map = new TtlMap(1000);
63+
map.set('key', 'v1');
64+
65+
vi.advanceTimersByTime(800);
66+
map.set('key', 'v2');
67+
68+
vi.advanceTimersByTime(800);
69+
70+
// 800ms since last set — still alive
71+
expect(map.get('key')).toBe('v2');
72+
73+
vi.advanceTimersByTime(200);
74+
75+
// 1000ms since last set — expired
76+
expect(map.has('key')).toBe(false);
77+
});
78+
});

0 commit comments

Comments
 (0)