Skip to content

Commit db629f9

Browse files
sirtimidclaude
andcommitted
fix(ocap-kernel): harden relay management, tighten validation, fix docs
Address review findings across the bounded relay management feature: tighten RelayEntryStruct to reject NaN/Infinity/negative/empty values, unify read/write validation paths, harden initRemoteIdentity return value, add defensive cap validation, prevent mnemonic leaking to platform services, simplify RPC handler and options passing, fix inaccurate SES/hardened-object comments and stale JSDoc references, and add missing test coverage for edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1dd4bc9 commit db629f9

8 files changed

Lines changed: 162 additions & 67 deletions

File tree

packages/ocap-kernel/CHANGELOG.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
### Fixed
11-
12-
- Deserialize CapData rejections in `Kernel.queueMessage` so vat errors surface as plain `Error` objects to all callers ([#928](https://github.com/MetaMask/ocap-kernel/pull/928))
13-
1410
### Changed
1511

1612
- Bound relay hints in OCAP URLs to a maximum of 3 and cap the relay pool at 20 entries with eviction of oldest non-bootstrap relays ([#929](https://github.com/MetaMask/ocap-kernel/pull/929))
1713

14+
### Fixed
15+
16+
- Deserialize CapData rejections in `Kernel.queueMessage` so vat errors surface as plain `Error` objects to all callers ([#928](https://github.com/MetaMask/ocap-kernel/pull/928))
17+
1818
## [0.7.0]
1919

2020
### Added

packages/ocap-kernel/src/remotes/kernel/remote-comms.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,8 @@ describe('remote-comms', () => {
581581
identity.addKnownRelays(['/dns4/relay.example/tcp/443/wss/p2p-circuit']);
582582
const firstSeen = mockKernelStore.getRelayEntries()[0]?.lastSeen ?? 0;
583583

584-
// SES lockdown prevents mocking Date.now; use a real delay instead
584+
// SES lockdown freezes Date, preventing vi.useFakeTimers(); use a
585+
// real delay to guarantee a different Date.now() value.
585586
await new Promise((resolve) => {
586587
setTimeout(resolve, 50);
587588
});
@@ -591,6 +592,19 @@ describe('remote-comms', () => {
591592
expect(secondSeen).toBeGreaterThan(firstSeen);
592593
});
593594

595+
it('addKnownRelays does not add duplicate entries on re-observation', async () => {
596+
const { identity } = await initRemoteIdentity(mockKernelStore);
597+
598+
const relay = '/dns4/relay.example/tcp/443/wss/p2p-circuit';
599+
identity.addKnownRelays([relay]);
600+
expect(mockKernelStore.getRelayEntries()).toHaveLength(1);
601+
602+
// Re-add the same relay — entry count must stay at 1
603+
identity.addKnownRelays([relay, relay]);
604+
expect(mockKernelStore.getRelayEntries()).toHaveLength(1);
605+
expect(mockKernelStore.getRelayEntries()[0]?.addr).toBe(relay);
606+
});
607+
594608
it('init clears bootstrap flag on relays removed from the bootstrap set', async () => {
595609
await initRemoteIdentity(mockKernelStore, {
596610
relays: ['/dns4/relayA.example/tcp/443/wss/p2p-circuit'],
@@ -775,6 +789,53 @@ describe('remote-comms', () => {
775789
);
776790
});
777791

792+
it('init truncates pool when bootstrap count exceeds maxKnownRelays', async () => {
793+
const bootstrapRelays = Array.from(
794+
{ length: 5 },
795+
(_, i) => `/dns4/bootstrap${i}.example/tcp/443/wss/p2p-circuit`,
796+
);
797+
await initRemoteIdentity(mockKernelStore, {
798+
relays: bootstrapRelays,
799+
maxKnownRelays: 3,
800+
});
801+
802+
const entries = mockKernelStore.getRelayEntries();
803+
// Pool must be capped at maxKnownRelays even when all entries are bootstrap
804+
expect(entries).toHaveLength(3);
805+
// All surviving entries should still be marked as bootstrap
806+
expect(entries.every((entry) => entry.isBootstrap)).toBe(true);
807+
});
808+
809+
it('issueOcapURL includes all relays when count equals maxUrlRelayHints', async () => {
810+
const relays = Array.from(
811+
{ length: DEFAULT_MAX_URL_RELAY_HINTS },
812+
(_, i) => `/dns4/relay${i}.example/tcp/443/wss/p2p-circuit`,
813+
);
814+
const { identity } = await initRemoteIdentity(mockKernelStore, {
815+
relays,
816+
});
817+
const ocapURL = await identity.issueOcapURL('ko1' as KRef);
818+
const { hints } = parseOcapURL(ocapURL);
819+
expect(hints).toHaveLength(DEFAULT_MAX_URL_RELAY_HINTS);
820+
expect(hints).toStrictEqual(relays);
821+
});
822+
823+
it.each([
824+
['maxUrlRelayHints', { maxUrlRelayHints: 0 }],
825+
['maxUrlRelayHints', { maxUrlRelayHints: 1.5 }],
826+
['maxUrlRelayHints', { maxUrlRelayHints: -1 }],
827+
['maxKnownRelays', { maxKnownRelays: 0 }],
828+
['maxKnownRelays', { maxKnownRelays: 1.5 }],
829+
['maxKnownRelays', { maxKnownRelays: -1 }],
830+
] as const)(
831+
'throws when %s is not a positive integer (%o)',
832+
async (_label, opts) => {
833+
await expect(initRemoteIdentity(mockKernelStore, opts)).rejects.toThrow(
834+
/must be positive integers/u,
835+
);
836+
},
837+
);
838+
778839
it('throws with mnemonic when identity already exists', async () => {
779840
mockKernelStore.setRemoteIdentityValue('peerId', 'existing-peer-id');
780841
mockKernelStore.setRemoteIdentityValue(

packages/ocap-kernel/src/remotes/kernel/remote-comms.ts

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ export const DEFAULT_MAX_URL_RELAY_HINTS = 3;
3939
export const DEFAULT_MAX_KNOWN_RELAYS = 20;
4040

4141
/**
42-
* Enforce the relay pool cap by keeping all bootstrap entries and the newest
43-
* non-bootstrap entries, up to `cap`.
42+
* Enforce the relay pool cap by prioritizing bootstrap entries, then the
43+
* newest non-bootstrap entries, up to `cap`. If bootstrap entries alone
44+
* exceed the cap, the result is truncated to `cap` (some bootstrap entries
45+
* may be dropped).
4446
*
4547
* @param entries - The full set of relay entries.
4648
* @param cap - The maximum pool size.
@@ -118,14 +120,14 @@ async function generateKeyInfo(seedString?: string): Promise<[string, string]> {
118120
* operations) without starting any network communications.
119121
*
120122
* @param kernelStore - The kernel store, for storing persistent key info.
121-
* @param options - Options for identity initialization.
122-
* @param options.relays - Bootstrap relay addresses. These are merged into
123+
* @param [options] - Options for identity initialization.
124+
* @param [options.relays] - Bootstrap relay addresses. These are merged into
123125
* the relay pool, marked as bootstrap (prioritized during eviction and URL
124126
* selection), and persisted. Relays previously marked as bootstrap that are
125127
* no longer in this list have their bootstrap flag cleared.
126-
* @param options.mnemonic - BIP39 mnemonic for seed recovery.
127-
* @param options.maxUrlRelayHints - Cap on relay hints per OCAP URL.
128-
* @param options.maxKnownRelays - Cap on the stored relay pool.
128+
* @param [options.mnemonic] - BIP39 mnemonic for seed recovery.
129+
* @param [options.maxUrlRelayHints] - Cap on relay hints per OCAP URL.
130+
* @param [options.maxKnownRelays] - Cap on the stored relay pool.
129131
* @param logger - The logger to use.
130132
* @param keySeed - Optional seed for key generation.
131133
* @returns the identity object, the key seed, and known relays.
@@ -155,8 +157,20 @@ export async function initRemoteIdentity(
155157
options?.maxUrlRelayHints ?? DEFAULT_MAX_URL_RELAY_HINTS;
156158
const maxKnownRelays = options?.maxKnownRelays ?? DEFAULT_MAX_KNOWN_RELAYS;
157159

160+
if (
161+
!Number.isInteger(maxUrlRelayHints) ||
162+
maxUrlRelayHints < 1 ||
163+
!Number.isInteger(maxKnownRelays) ||
164+
maxKnownRelays < 1
165+
) {
166+
throw Error(
167+
`maxUrlRelayHints (${maxUrlRelayHints}) and maxKnownRelays (${maxKnownRelays}) must be positive integers`,
168+
);
169+
}
170+
158171
if (relays.length > 0) {
159-
// Date.now() is available under SES lockdown (on the intrinsics allow-list).
172+
// Date.now() works here because this code runs in the start compartment,
173+
// which retains the original Date constructor (%InitialDate%).
160174
const now = Date.now();
161175
const bootstrapSet = new Set(relays);
162176

@@ -174,7 +188,7 @@ export async function initRemoteIdentity(
174188
byAddr.set(addr, { addr, lastSeen: now, isBootstrap: true });
175189
}
176190
// Clear bootstrap flag on entries no longer in the current bootstrap set
177-
// (create new objects to avoid mutating potentially-hardened entries)
191+
// (create new objects to follow immutability conventions)
178192
for (const [addr, entry] of byAddr.entries()) {
179193
if (entry.isBootstrap && !bootstrapSet.has(addr)) {
180194
byAddr.set(addr, { ...entry, isBootstrap: false });
@@ -269,15 +283,17 @@ export async function initRemoteIdentity(
269283
/**
270284
* Add relay addresses to the kernel's known relay pool.
271285
* Deduplicates, updates lastSeen on re-observation, and enforces
272-
* {@link MAX_KNOWN_RELAYS} by evicting the oldest non-bootstrap entries.
286+
* the configured `maxKnownRelays` cap by evicting the oldest
287+
* non-bootstrap entries.
273288
*
274289
* @param newRelays - Relay multiaddrs to add.
275290
*/
276291
function addKnownRelays(newRelays: string[]): void {
277292
if (newRelays.length === 0) {
278293
return;
279294
}
280-
// Date.now() is available under SES lockdown (on the intrinsics allow-list).
295+
// Date.now() works here because this code runs in the start compartment,
296+
// which retains the original Date constructor (%InitialDate%).
281297
const now = Date.now();
282298
const existing = kernelStore.getRelayEntries();
283299
const byAddr = new Map(existing.map((entry) => [entry.addr, entry]));
@@ -286,8 +302,8 @@ export async function initRemoteIdentity(
286302
for (const addr of newRelays) {
287303
const entry = byAddr.get(addr);
288304
if (entry) {
289-
// Update lastSeen on re-observation (create new object to avoid
290-
// mutating potentially-hardened deserialized entries)
305+
// Update lastSeen on re-observation (create new object to follow
306+
// immutability conventions)
291307
if (entry.lastSeen !== now) {
292308
byAddr.set(addr, { ...entry, lastSeen: now });
293309
changed = true;
@@ -339,11 +355,16 @@ export async function initRemoteIdentity(
339355
return kref;
340356
}
341357

342-
return {
343-
identity: { getPeerId, issueOcapURL, redeemLocalOcapURL, addKnownRelays },
358+
return harden({
359+
identity: harden({
360+
getPeerId,
361+
issueOcapURL,
362+
redeemLocalOcapURL,
363+
addKnownRelays,
364+
}),
344365
keySeed,
345366
knownRelays: kernelStore.getKnownRelayAddresses(),
346-
};
367+
});
347368
}
348369

349370
/**
@@ -352,7 +373,7 @@ export async function initRemoteIdentity(
352373
* @param kernelStore - The kernel store, for storing persistent key info.
353374
* @param platformServices - The platform services, for accessing network I/O
354375
* operations that are not available within the web worker that the kernel runs in.
355-
* @param remoteMessageHandler - Handler to process received inbound communcations.
376+
* @param remoteMessageHandler - Handler to process received inbound communications.
356377
* @param options - Options for remote communications initialization.
357378
* @param logger - The logger to use.
358379
* @param keySeed - Optional seed for libp2p key generation.
@@ -373,16 +394,17 @@ export async function initRemoteComms(
373394
incarnationId?: string,
374395
onIncarnationChange?: OnIncarnationChange,
375396
): Promise<RemoteComms> {
376-
const { relays = [], mnemonic, maxUrlRelayHints, maxKnownRelays } = options;
397+
const {
398+
relays = [],
399+
mnemonic,
400+
maxUrlRelayHints,
401+
maxKnownRelays,
402+
...platformOptions
403+
} = options;
377404

378405
const result = await initRemoteIdentity(
379406
kernelStore,
380-
{
381-
relays,
382-
...(mnemonic === undefined ? {} : { mnemonic }),
383-
maxUrlRelayHints,
384-
maxKnownRelays,
385-
},
407+
{ relays, mnemonic, maxUrlRelayHints, maxKnownRelays },
386408
logger,
387409
keySeed,
388410
);
@@ -395,9 +417,11 @@ export async function initRemoteComms(
395417
// called before initializeRemoteComms to capture the pre-restart timestamp.
396418
const wakeDetected = kernelStore.detectWake();
397419

420+
// Omit mnemonic (sensitive key material) from the options passed to
421+
// platform services, which only needs network-level configuration.
398422
await platformServices.initializeRemoteComms(
399423
result.keySeed,
400-
{ ...options, relays: knownRelays },
424+
{ ...platformOptions, relays: knownRelays },
401425
remoteMessageHandler,
402426
onRemoteGiveUp,
403427
incarnationId,

packages/ocap-kernel/src/remotes/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,10 @@ export type RemoteCommsOptions = {
148148
maxUrlRelayHints?: number | undefined;
149149
/**
150150
* Maximum number of relay entries stored in the kernel's relay pool
151-
* (default: 20). The pool retains all bootstrap relays; when the cap is
152-
* reached, the oldest non-bootstrap (learned) entries are evicted first.
151+
* (default: 20). Bootstrap relays are prioritized during eviction; when
152+
* the cap is reached, the oldest non-bootstrap (learned) entries are
153+
* evicted first. If bootstrap relays alone exceed the cap, the pool is
154+
* truncated to the cap.
153155
*/
154156
maxKnownRelays?: number | undefined;
155157
/**

packages/ocap-kernel/src/rpc/kernel-control/init-remote-comms.ts

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -59,28 +59,13 @@ export const initRemoteCommsHandler: Handler<
5959
...initRemoteCommsSpec,
6060
hooks: { kernel: true },
6161
implementation: async ({ kernel }, params): Promise<null> => {
62-
const options: RemoteCommsOptions = {};
63-
if (params.relays !== undefined) {
64-
options.relays = params.relays;
65-
}
66-
if (params.directListenAddresses !== undefined) {
67-
options.directListenAddresses = params.directListenAddresses;
68-
}
69-
if (params.maxRetryAttempts !== undefined) {
70-
options.maxRetryAttempts = params.maxRetryAttempts;
71-
}
72-
if (params.maxQueue !== undefined) {
73-
options.maxQueue = params.maxQueue;
74-
}
75-
if (params.maxUrlRelayHints !== undefined) {
76-
options.maxUrlRelayHints = params.maxUrlRelayHints;
77-
}
78-
if (params.maxKnownRelays !== undefined) {
79-
options.maxKnownRelays = params.maxKnownRelays;
80-
}
81-
if (params.allowedWsHosts !== undefined) {
82-
options.allowedWsHosts = params.allowedWsHosts;
83-
}
62+
// Build options from only the defined RPC params. Superstruct has
63+
// already validated the shape; stripping undefined keeps the bag sparse.
64+
// Note: sensitive fields like `mnemonic` and internal fields like
65+
// `directTransports` are intentionally excluded from the RPC struct.
66+
const options: RemoteCommsOptions = Object.fromEntries(
67+
Object.entries(params).filter(([, value]) => value !== undefined),
68+
);
8469
await kernel.initRemoteComms(options);
8570
return null;
8671
},

packages/ocap-kernel/src/store/methods/relay.test.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,13 @@ describe('getRelayMethods', () => {
9191
'knownRelays',
9292
JSON.stringify([{ addr: 'ok', lastSeen: 'bad', isBootstrap: false }]),
9393
);
94-
expect(() => methods.getRelayEntries()).toThrow(
95-
'knownRelays entries must have addr, lastSeen, isBootstrap',
96-
);
94+
expect(() => methods.getRelayEntries()).toThrow(/Invalid stored relay/u);
95+
});
96+
97+
it('returns empty array when stored value is an empty JSON array', () => {
98+
const { kv, methods } = makeCtx();
99+
kv.set('knownRelays', '[]');
100+
expect(methods.getRelayEntries()).toStrictEqual([]);
97101
});
98102
});
99103

@@ -106,6 +110,24 @@ describe('getRelayMethods', () => {
106110
]),
107111
).toThrow(/Invalid relay entry/u);
108112
});
113+
114+
it.each([
115+
['empty addr', { addr: '', lastSeen: 0, isBootstrap: false }],
116+
[
117+
'negative lastSeen',
118+
{ addr: 'relay1', lastSeen: -1, isBootstrap: false },
119+
],
120+
['NaN lastSeen', { addr: 'relay1', lastSeen: NaN, isBootstrap: false }],
121+
[
122+
'Infinity lastSeen',
123+
{ addr: 'relay1', lastSeen: Infinity, isBootstrap: false },
124+
],
125+
])('rejects entry with %s', (_label, entry) => {
126+
const { methods } = makeCtx();
127+
expect(() => methods.setRelayEntries([entry])).toThrow(
128+
/Invalid relay entry/u,
129+
);
130+
});
109131
});
110132

111133
describe('getKnownRelayAddresses', () => {

packages/ocap-kernel/src/store/methods/relay.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ export function getRelayMethods(ctx: {
2323
const { kv, logger } = ctx;
2424

2525
/**
26-
* Parse a JSON string, wrapping errors with a contextual message.
26+
* Parse the knownRelays JSON string from storage, wrapping errors with
27+
* a contextual message.
2728
*
2829
* @param raw - The raw JSON string to parse.
2930
* @returns The parsed value.
@@ -36,6 +37,7 @@ export function getRelayMethods(ctx: {
3637
`Failed to parse knownRelays from store (value may be corrupted): ${
3738
error instanceof Error ? error.message : String(error)
3839
}`,
40+
{ cause: error },
3941
);
4042
}
4143
}
@@ -78,12 +80,7 @@ export function getRelayMethods(ctx: {
7880
// Validate each entry against the RelayEntry schema (entries deserialized
7981
// from storage may have been written by an older version or corrupted)
8082
for (const entry of parsed) {
81-
(typeof entry === 'object' &&
82-
entry !== null &&
83-
typeof (entry as RelayEntry).addr === 'string' &&
84-
typeof (entry as RelayEntry).lastSeen === 'number' &&
85-
typeof (entry as RelayEntry).isBootstrap === 'boolean') ||
86-
Fail`knownRelays entries must have addr, lastSeen, isBootstrap`;
83+
assert(entry, RelayEntryStruct, 'Invalid stored relay entry');
8784
}
8885
return parsed as RelayEntry[];
8986
}

0 commit comments

Comments
 (0)