Skip to content

Commit 828fd00

Browse files
rekmarksclaude
andcommitted
refactor(ocap-kernel): neutral remote identity (netlayer phase 2)
Make the kernel's peer identity netlayer-neutral and remove libp2p-specific crypto from the identity/URL/error layer: - Add remotes/kernel/identity.ts: deriveNeutralPeerId / neutralPeerIdToPublicKey / publicKeyToNeutralPeerId (base58btc multibase of the raw Ed25519 public key via @noble/curves), exported from the package barrel. - remote-comms.ts derives the neutral peerId from the seed and encrypts ocap-URL oids with WebCrypto AES-256-GCM (crypto.subtle, key used directly, random IV) instead of @libp2p/crypto AES_GCM/Ed25519. - connection-factory.ts converts neutral <-> libp2p peerIds at the libp2p boundary (dial, inbound handler, peer:disconnect); the transport layer only ever sees neutral ids. - kernel-errors drops its @libp2p/interface import: isRetryableNetworkError classifies ChannelResetError and matches MuxerClosedError by name. - ocap: URL host is now the neutral z... id; format otherwise unchanged. Compatibility breaks by design (no migration). Verified with fresh storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6587aa0 commit 828fd00

18 files changed

Lines changed: 584 additions & 135 deletions

docs/identity-backup-recovery.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ The OCAP Kernel supports BIP39 mnemonic phrases for backing up and recovering ke
44

55
## Overview
66

7+
> **Format change**: The peer ID is now the multibase base58btc encoding of the
8+
> raw 32-byte Ed25519 public key (a `z…` string), independent of any network
9+
> transport. Peer IDs and `ocap:` URLs issued before this change (which used the
10+
> libp2p `12D3KooW…` peer ID form) are not compatible; recover onto fresh storage.
11+
712
Each kernel has a unique identity derived from a cryptographic seed. This identity determines the kernel's peer ID, which is used for peer-to-peer communication. By default, the kernel generates a random seed on first initialization. With BIP39 support, you can:
813

914
- **Create a recoverable identity** by generating a mnemonic and using it during initialization
@@ -57,7 +62,7 @@ if (isValidMnemonic(mnemonic)) {
5762
}
5863
```
5964

60-
#### `mnemonicToSeed(mnemonic: string): string`
65+
#### `mnemonicToSeed(mnemonic: string): Promise<string>`
6166

6267
Converts a BIP39 mnemonic phrase to a 32-byte hex-encoded seed using standard PBKDF2 derivation.
6368

@@ -68,7 +73,7 @@ import { mnemonicToSeed } from '@metamask/ocap-kernel';
6873

6974
const mnemonic =
7075
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
71-
const seed = mnemonicToSeed(mnemonic);
76+
const seed = await mnemonicToSeed(mnemonic);
7277
// seed is a 64-character hex string (32 bytes)
7378
```
7479

@@ -173,23 +178,24 @@ console.log('Recovered Peer ID:', status.remoteComms?.peerId);
173178
To verify a mnemonic will produce the expected peer ID without actually initializing:
174179

175180
```typescript
176-
import { mnemonicToSeed, isValidMnemonic } from '@metamask/ocap-kernel';
177-
import { generateKeyPairFromSeed } from '@libp2p/crypto/keys';
178-
import { peerIdFromPrivateKey } from '@libp2p/peer-id';
181+
import {
182+
mnemonicToSeed,
183+
isValidMnemonic,
184+
deriveNeutralPeerId,
185+
} from '@metamask/ocap-kernel';
179186
import { fromHex } from '@metamask/kernel-utils';
180187

181188
async function getPeerIdFromMnemonic(mnemonic: string): Promise<string> {
182189
if (!isValidMnemonic(mnemonic)) {
183190
throw new Error('Invalid mnemonic');
184191
}
185192

186-
const seed = mnemonicToSeed(mnemonic);
187-
const keyPair = await generateKeyPairFromSeed('Ed25519', fromHex(seed));
188-
return peerIdFromPrivateKey(keyPair).toString();
193+
const seed = await mnemonicToSeed(mnemonic);
194+
return deriveNeutralPeerId(fromHex(seed));
189195
}
190196

191197
// Verify before recovery
192-
const expectedPeerId = '12D3KooW...'; // Your known peer ID
198+
const expectedPeerId = 'z6Mk...'; // Your known (neutral) peer ID
193199
const recoveryMnemonic = 'your recovery phrase';
194200

195201
const recoveredPeerId = await getPeerIdFromMnemonic(recoveryMnemonic);

docs/plans/netlayer/master.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,57 @@ stopController })` (synchronous engine) and keeps `initTransport(...)` as a sign
197197
`remote-comms.ts``@noble/curves` + WebCrypto + neutral peerId; id conversion added to
198198
`connection-factory.ts`; kernel-errors drops libp2p imports; `ocap:` URL format changes.
199199
Verify: two-kernel relay integration test, URL round-trip tests, fresh-storage e2e.
200+
**DONE** (issue #968, PR on branch `rekm/netlayer-2`, stacked on `rekm/netlayer-1`).
201+
202+
Landed decisions / deviations from the phase-2 plan (these are the facts Phase 3 must build on):
203+
204+
- **Neutral identity helpers live in `packages/ocap-kernel/src/remotes/kernel/identity.ts`**
205+
(libp2p-free; `@noble/curves/ed25519` + `multiformats/bases/base58` only), exported from the
206+
ocap-kernel barrel (`src/index.ts`) as `deriveNeutralPeerId`, `neutralPeerIdToPublicKey`,
207+
`publicKeyToNeutralPeerId`. Signatures: `deriveNeutralPeerId(seed: Uint8Array): string`,
208+
`neutralPeerIdToPublicKey(peerId: string): Uint8Array` (throws on wrong length),
209+
`publicKeyToNeutralPeerId(publicKey: Uint8Array): string`. There is **no** `deriveNetlayerIdentity`
210+
wrapper and no key-material struct — the peerId is just the base58btc string. Phase 3 moves this
211+
whole file to `@metamask/netlayer` and re-exports it from ocap-kernel.
212+
- **peerId encoding:** multibase base58btc (`z…` prefix) of the raw 32-byte Ed25519 public key,
213+
i.e. `base58btc.encode(ed25519.getPublicKey(seed))`. The `keySeed` remains a 32-byte hex seed,
214+
unchanged. Vector: all-zero seed → `z4zvwRjXUKGfvwnParsHAS3HuSVzV5cA4McphgmoCtajS`.
215+
- **`ocap:` URL format** (string shape `ocap:{oid}@{host}[,{hint}]*` unchanged): `host` is now the
216+
neutral `z…` id; `oid` is `base58btc.encode(iv(12) ‖ ciphertext ‖ gcmTag(16))` using WebCrypto
217+
`crypto.subtle` AES-256-GCM with the 32-byte `ocapURLKey` used **directly** (no PBKDF2/salt),
218+
a fresh random 12-byte IV per encryption. `hints` are still opaque libp2p relay multiaddrs.
219+
- **remote-comms.ts crypto:** the AES key is imported lazily+memoized inside `initRemoteIdentity`
220+
via a `getAesKey()` closure (`crypto.subtle.importKey('raw', ocapURLKey, {name:'AES-GCM'}, false,
221+
['encrypt','decrypt'])`), so identities that never issue/redeem a URL never touch `crypto.subtle`.
222+
The "amateur cryptography" warning is retained. `KREF_MIN_LEN` (16) padding retained.
223+
- **id conversion at the libp2p boundary lives in `connection-factory.ts`** (still in ocap-kernel
224+
until Phase 4): a private `#toLibp2pPeerId(neutralId)` = `peerIdFromPublicKey(publicKeyFromRaw(
225+
neutralPeerIdToPublicKey(neutralId))).toString()`. `dial(neutralPeerId, hints, withRetry)` keeps
226+
`#inflightDials` keyed by the **neutral** id and converts to the libp2p id only for
227+
`candidateAddressStrings`/`openChannel*`; `openChannelOnce`/`openChannelWithRetry` gained a third
228+
`neutralPeerId` param (defaults to the libp2p `peerId` arg) that is stamped onto the returned
229+
channel via `#makeNetworkChannel(stream, neutralPeerId)`. Inbound handler and `peer:disconnect`
230+
derive the neutral id from `connection.remotePeer.publicKey.raw` /
231+
`evt.detail.publicKey.raw` via `publicKeyToNeutralPeerId`, and **drop** (log + return) if the
232+
public key is absent. The `#relayPeerIds` guard stays in libp2p-id space. `connection-factory.ts`
233+
therefore imports `neutralPeerIdToPublicKey`/`publicKeyToNeutralPeerId` from `../kernel/identity.ts`
234+
**and** `publicKeyFromRaw` (`@libp2p/crypto/keys`) + `peerIdFromPublicKey` (`@libp2p/peer-id`).
235+
**Phase 3 note:** when `identity.ts` moves to `@metamask/netlayer`, `connection-factory.ts`
236+
(which stays in ocap-kernel until Phase 4) must import those two neutral helpers back from
237+
`@metamask/netlayer`.
238+
- **`@metamask/kernel-errors` is now libp2p-free.** `isRetryableNetworkError` no longer imports
239+
`@libp2p/interface`: it imports `ChannelResetError` (retryable) and classifies `MuxerClosedError`
240+
by `error.name` (behavior-preserving). The `MuxerClosedError`/`Dial`/`Transport`/`NO_RESERVATION`
241+
name/message sniffing is annotated as libp2p-specific and slated to move to
242+
`@metamask/netlayer-libp2p`'s error mapper in Phase 4. `@libp2p/interface` removed from
243+
`kernel-errors/package.json` (its only libp2p usage).
244+
- **Deps:** `@noble/curves` (`^1.9.7`) added to `ocap-kernel/package.json`. All `@libp2p/*`,
245+
`libp2p`, `@chainsafe/*`, `@multiformats/multiaddr`, `multiformats` deps **stay** in ocap-kernel
246+
(connection-factory still uses them); removal is Phase 4. `@libp2p/crypto`/`@libp2p/peer-id`
247+
removed from the private `@ocap/kernel-test` package (its fake platform-services now uses
248+
`deriveNeutralPeerId`).
249+
- **No migration:** persisted `peerId`/`ocapURLKey` and previously issued `ocap:` URLs are
250+
incompatible by design; verification used fresh storage.
200251

201252
**Phase 3 — Create `@metamask/netlayer` + `@metamask/netlayer-loopback`.** ~2–3 days.
202253
Move shared machinery + engine + types (with tests) out of `remotes/platform/`; ocap-kernel

0 commit comments

Comments
 (0)