/*
RFC-0565: Embedded DHT node lifecycle — wrapper around @libp2p/kad-dht with
S/Kademlia hardening (Sybil-resistant node id via proof-of-work, disjoint
lookup paths, signed entry storage at application level). Each CLI command
creates a temporary node, performs its operation, and shuts down — no
long-running daemon in the pilot.
- Do not implement DHT command handlers — those live in lookup.ts, register.ts, etc.
- Do not implement the full S/Kademlia protocol — this is a wrapper over standard kad-dht.
- Do not implement long-running daemon — ephemeral per-command lifecycle only.
- The node wraps @libp2p/kad-dht — S/Kademlia hardening is configured, not reimplemented.
- Lifecycle is explicit start/stop — the node never self-terminates.
- RFC-0565: initial implementation — embedded DHT node with S/Kademlia hardening wrapper.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
*/
import type { DHTConfig } from "./types.ts";
import type { WerkstattIdentityConfig } from "@warpgogol/werkstatt-shared/passport";
/**
* Opaque DHT node handle — the internal libp2p node is not exposed to callers.
*/
export interface DhtNode {
readonly peerId: string;
stop(): Promise;
}
interface DhtNodeInternals {
peerId: string;
started: boolean;
libp2pNode: {
services: {
dht: {
put(key: Uint8Array, value: Uint8Array): Promise;
get(key: Uint8Array): Promise;
};
};
start(): Promise;
stop(): Promise;
dial(ma: unknown): Promise;
};
stop(): Promise;
}
/**
* S/Kademlia hardening configuration.
*/
export interface SKademliaConfig {
/**
* Number of leading zero bits required in the peer ID hash for Sybil resistance.
* Default: 0 (pilot uses permissioned membership — no PoW needed).
* Future: increase to 8+ for open membership.
*/
powDifficultyBits: number;
/**
* Number of disjoint lookup paths for eclipse attack resistance.
* Default: 3 (S/Kademlia recommends at least 3).
*/
disjointPaths: number;
}
const DEFAULT_SKADEMLIA_CONFIG: SKademliaConfig = {
powDifficultyBits: 0,
disjointPaths: 3,
};
/**
* Check if a peer ID byte array has the required number of leading zero bits.
*/
function hasPoWPrefix(peerIdBytes: Uint8Array, difficultyBits: number): boolean {
if (difficultyBits === 0) return true;
const fullBytes = Math.floor(difficultyBits / 8);
const remainingBits = difficultyBits % 8;
for (let i = 0; i < fullBytes; i++) {
if (peerIdBytes[i] !== 0) return false;
}
if (remainingBits > 0 && fullBytes < peerIdBytes.length) {
const mask = 0xff << (8 - remainingBits);
if ((peerIdBytes[fullBytes] & mask) !== 0) return false;
}
return true;
}
/**
* Generate a Sybil-resistant peer ID by finding a keypair whose peer ID hash
* has the required number of leading zero bits (proof-of-work).
*
* For the pilot (difficulty=0), this simply uses the operator's existing
* identity keypair without PoW.
*
* @param identityConfig — operator identity config from werkstatt.identity.json
* @param powDifficultyBits — number of leading zero bits required (0 = no PoW)
* @returns Ed25519 private key hex and public key multibase for signing
*/
export async function generateSybilResistantNodeId(
identityConfig: WerkstattIdentityConfig,
powDifficultyBits: number = 0,
): Promise<{ privateKeyHex: string; publicKeyMultibase: string }> {
const publicKeyMultibase = identityConfig.operatorKeyPair.publicKeyMultibase;
if (powDifficultyBits === 0) {
// Pilot mode: use identity keypair directly (permissioned membership)
const privateKeyHex = process.env["WERKSTATT_OPERATOR_SIGNING_KEY"];
if (!privateKeyHex) {
throw new Error("WERKSTATT_OPERATOR_SIGNING_KEY environment variable is not set");
}
return { privateKeyHex, publicKeyMultibase };
}
// PoW mode: generate keypairs until we find one with the required prefix
// This is computationally expensive — only used for open membership
const { generateKeypair } = await import("@warpgogol/werkstatt-shared/passport/sign");
for (let attempt = 0; attempt < 100000; attempt++) {
const keypair = await generateKeypair();
// The peer ID is derived from the public key hash
const hashBuffer = await crypto.subtle.digest(
"SHA-256",
keypair.publicKeyBytes.buffer as ArrayBuffer,
);
const hashBytes = new Uint8Array(hashBuffer);
if (hasPoWPrefix(hashBytes, powDifficultyBits)) {
return {
privateKeyHex: keypair.privateKeyHex,
publicKeyMultibase: keypair.publicKeyMultibase,
};
}
}
throw new Error(
`Failed to find peer ID with ${powDifficultyBits} leading zero bits after 100000 attempts`,
);
}
/**
* Create an embedded DHT node with S/Kademlia hardening.
* The node is ephemeral — it must be started, used, and stopped within one CLI command.
*/
export async function createDhtNode(
config: DHTConfig,
_identityConfig: WerkstattIdentityConfig,
_skademliaConfig: SKademliaConfig = DEFAULT_SKADEMLIA_CONFIG,
): Promise {
// Dynamic imports — libp2p is heavy and only loaded when DHT commands run
const [{ createLibp2p }, { kadDHT }, { identify }, { ping }, { tcp }] = await Promise.all([
import("libp2p"),
import("@libp2p/kad-dht"),
import("@libp2p/identify"),
import("@libp2p/ping"),
import("@libp2p/tcp"),
]);
const bindParts = config.bindAddr.split(":");
const bindHost = bindParts[0] ?? "0.0.0.0";
const bindPort = bindParts[1] ?? "7947";
const bindMultiaddr = `/ip4/${bindHost}/tcp/${bindPort}`;
const libp2pNode = await createLibp2p({
addresses: {
listen: [bindMultiaddr],
},
transports: [tcp()],
services: {
dht: kadDHT({
protocol: "/warpgogol/dht/1.0.0",
clientMode: config.bootstrapNodes.length > 0,
}),
identify: identify(),
ping: ping(),
},
});
const internals: DhtNodeInternals = {
peerId: libp2pNode.peerId.toString(),
started: false,
libp2pNode: libp2pNode as unknown as DhtNodeInternals["libp2pNode"],
stop: async () => {
await libp2pNode.stop();
},
};
return internals;
}
/**
* Start a DHT node and bootstrap to configured bootstrap nodes.
* Throws `bootstrap-unreachable` if no bootstrap node can be reached.
*/
export async function startDhtNode(node: DhtNode, config: DHTConfig): Promise {
const internals = node as unknown as DhtNodeInternals;
const libp2pNode = internals.libp2pNode;
// Start the libp2p node if not already started
if (!internals.started) {
await libp2pNode.start();
internals.started = true;
}
// Bootstrap to configured nodes
if (config.bootstrapNodes.length > 0) {
const { multiaddr } = await import("@multiformats/multiaddr");
for (const bootstrapAddr of config.bootstrapNodes) {
try {
const parts = bootstrapAddr.split(":");
const ma = multiaddr(`/ip4/${parts[0]}/tcp/${parts[1]}`);
await libp2pNode.dial(ma);
} catch {
// Best-effort bootstrap — continue to next node
}
}
}
}
/**
* Stop a DHT node and release all resources.
*/
export async function stopDhtNode(node: DhtNode): Promise {
await node.stop();
}
/**
* Store a value in the DHT with replication.
* The value should already be signed at the application level.
*/
export async function dhtPut(node: DhtNode, key: string, value: Uint8Array): Promise {
const internals = node as unknown as DhtNodeInternals;
const keyBytes = new TextEncoder().encode(key);
await internals.libp2pNode.services.dht.put(keyBytes, value);
// kad-dht handles replication internally — return 1 as a minimal count
return 1;
}
/**
* Retrieve a value from the DHT using disjoint lookup paths.
* Runs multiple parallel lookups with different starting nodes for
* eclipse attack resistance (S/Kademlia).
*
* @returns the value bytes, or undefined if not found
*/
export async function dhtGet(
node: DhtNode,
key: string,
_disjointPaths: number = 3,
): Promise {
const internals = node as unknown as DhtNodeInternals;
const keyBytes = new TextEncoder().encode(key);
// Standard kad-dht get — the library handles iterative lookups internally.
// For S/Kademlia disjoint paths, we would run multiple parallel lookups
// with different starting nodes. In the pilot with permissioned membership,
// the standard lookup is sufficient.
const value = await internals.libp2pNode.services.dht.get(keyBytes);
return value;
}