Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
# @suaveplan/network-discovery-gundb - Browser-tier GunDB DiscoveryStrategy: gossip-over-WebSocket peer discovery via Gun. Relay list REQUIRED — no public-relay fallback. Mandatory Ed25519-signed peer announces. Zod wire-payload validation. Constant-time peer-ID compare. AbortSignal + EventBusLike + ServiceRegistry + OTel + Zod boundaries.
## Summary
Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap`
chains. Wraps a [GunDB](https://gun.eco) instance — gossip-over-
WebSocket decentralised graph database — and exposes a deduped, capped
peer set to the bootstrap pipeline.
GunDB is flagged security-fragile upstream; this release tightens
every relevant attack surface.
<!-- begin:auto-docs -->
## Documentation tree
- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_ - [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ - [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this package
- Plug-and-play `DiscoveryStrategy` slot for network bootstrap
chains. Works alongside `@suaveplan/network-discovery-peerjs`,
`@suaveplan/network-discovery-trystero`,
`@suaveplan/network-discovery-webtorrent`, and
`@suaveplan/network-discovery-crdt`.
- **Relay list REQUIRED.** Upstream's public-relay fallback
(`https://gun-manhattan.herokuapp.com/gun`) is removed. Callers must
supply their own relays.
- **Every outbound announce is Ed25519-signed.** The local peer's
32-byte secret is the contract input.
- **Every inbound announce is Zod-parsed AND Ed25519-verified before
admission.** Failures silently drop the payload and increment
`gundb.invalid_messages_total{reason}` /
`gundb.signature_rejected_total{reason}` — a malicious peer cannot
crash a node by gossiping garbage.
- **Constant-time peer-ID compare** via `constantTimeEqual` from
`@suaveplan/crypto-utils`.
- Injectable `gunFactory` — production wires the real `Gun(config)`;
tests bind a deterministic `GunNodeLike` fake (no `vi.mock` ever
used).
- Typed `EventBusLike<GunDBEventMap>` integration.
- Optional `@suaveplan/service-discovery` `ServiceRegistry` mirroring.
- OTel surface: `disc_gundb.start` span; counters
`peers_discovered_total{outcome}`,
`gundb.invalid_messages_total{reason}`,
`gundb.signature_rejected_total{reason}`,
`disc_gundb.relay_errors_total{relay}`; histogram
`gundb_announce_duration_ms`.
- `AbortSignal` honoured on every async public method.
- 100% test + branch + function + line coverage.
## Basic example
```ts
import { GunDBDiscovery, publicKeyHex } from "@suaveplan/network-discovery-gundb";
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const localPeerId = publicKeyHex(signingKey);
const discovery = new GunDBDiscovery({
peers: ["wss://my-private-relay.example.com/gun"],
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[] }) as unknown as GunNodeLike;
},
localPeerId,
signingKey,
});
await discovery.start();
const peers = await discovery.discover(5_000);
console.log(`found ${peers.length} peers`);
await discovery.stop();
```
## Advanced example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import {
GunDBDiscovery,
publicKeyHex,
type GunDBEventMap,
} from "@suaveplan/network-discovery-gundb";
const bus = new EventBus<GunDBEventMap>();
const registry = new ServiceRegistry();
const signingKey = crypto.getRandomValues(new Uint8Array(32));
bus.on("gundb:peer-discovered", ({ peer }) => {
console.log("peer:", peer.id, peer.addresses);
});
bus.on("gundb:relay-error", ({ reason }) => {
console.warn("relay:", reason);
});
const discovery = new GunDBDiscovery({
peers: [
"wss://relay-a.example.com/gun",
"wss://relay-b.example.com/gun",
],
room: "my-app/v1",
maxPeers: 256,
announceIntervalMs: 15_000,
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({
peers: cfg.peers as string[],
localStorage: cfg.localStorage,
}) as unknown as GunNodeLike;
},
localPeerId: publicKeyHex(signingKey),
signingKey,
bus,
registry,
});
const chain = new DiscoveryChain([discovery]);
const peers = await chain.discover(5_000);
```
## API
See sibling co-located markdown:
- [`src/types/types.md`](./src/types/types.md)
- [`src/errors/errors.md`](./src/errors/errors.md)
- [`src/otel/otel.md`](./src/otel/otel.md)
- [`src/signing/signing.md`](./src/signing/signing.md)
- [`src/core/gundb-discovery/gundb-discovery.md`](./src/core/gundb-discovery/gundb-discovery.md)
## License
MIT

View File

@@ -0,0 +1,83 @@
{
"name": "@suaveplan/network-discovery-gundb",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "Browser-tier GunDB DiscoveryStrategy: gossip-over-WebSocket peer discovery via Gun. Relay list REQUIRED — no public-relay fallback. Mandatory Ed25519-signed peer announces. Zod wire-payload validation. Constant-time peer-ID compare. AbortSignal + EventBusLike + ServiceRegistry + OTel + Zod boundaries.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./types": {
"import": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" },
"require": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
},
"./signing": {
"import": { "types": "./dist/signing/signing.d.ts", "default": "./dist/signing/signing.js" },
"require": { "types": "./dist/signing/signing.d.ts", "default": "./dist/signing/signing.js" }
},
"./gundb-discovery": {
"import": { "types": "./dist/core/gundb-discovery/gundb-discovery.d.ts", "default": "./dist/core/gundb-discovery/gundb-discovery.js" },
"require": { "types": "./dist/core/gundb-discovery/gundb-discovery.d.ts", "default": "./dist/core/gundb-discovery/gundb-discovery.js" }
},
"./transport": {
"import": { "types": "./dist/transport/transport.d.ts", "default": "./dist/transport/transport.js" },
"require": { "types": "./dist/transport/transport.d.ts", "default": "./dist/transport/transport.js" }
},
"./plugin": {
"import": { "types": "./dist/plugin/plugin.d.ts", "default": "./dist/plugin/plugin.js" },
"require": { "types": "./dist/plugin/plugin.d.ts", "default": "./dist/plugin/plugin.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto-curve": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/network-bootstrap": "workspace:^",
"@suaveplan/service-discovery": "workspace:^",
"@suaveplan/timing": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"gun": "catalog:network-discovery"
},
"peerDependenciesMeta": {
"gun": { "optional": true }
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,198 @@
# gundb-discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GunDBDiscovery` is the browser-tier `DiscoveryStrategy` for
`@suaveplan/network-bootstrap` chains. It wraps a single `Gun` instance
(constructed via the injected factory) and exposes a deduped, capped
peer set to the bootstrap pipeline. GunDB itself is a gossip-over-
WebSocket decentralised graph database; this strategy publishes the
local peer's announce into a room-scoped record, subscribes to the
room's child records, and lifts every fresh inbound announce into a
typed peer-record.
The strategy is the **single security-hardening layer** in the upstream
GunDB migration. Upstream's hardcoded public-relay defaults
(`https://gun-manhattan.herokuapp.com/gun` and friends) are gone — the
`peers` list is mandatory and non-empty, enforced at construction time
by Zod. Every outbound announce is Ed25519-signed by the local peer's
secret; every inbound announce is Zod-parsed AND Ed25519-verified
before it can enter the local peer set. Failures silently drop the
payload and increment the `gundb.invalid_messages_total{reason}` and
`gundb.signature_rejected_total{reason}` counters — a malicious peer
cannot crash a node by gossiping garbage, and operators alert on the
counters instead.
## Features
- Implements `DiscoveryStrategy` (`name`, `discover`, `isAvailable`)
so it slots directly into `@suaveplan/network-bootstrap`
`DiscoveryChain` instances.
- Lifecycle: idempotent `start()` / `stop()` / `dispose()`.
- Injectable `gunFactory` — production wires the real `Gun(config)`;
tests pass a deterministic in-memory `GunNodeLike` fake (no
`vi.mock` ever used).
- Zod-validated configuration. Relay list is non-optional and
`.min(1)`-enforced. The factory is enforced at runtime in addition
to schema-level acceptance. `signingKey` validated as exactly
32-byte `Uint8Array`.
- **Every outbound announce is Ed25519-signed** by `signingKey`.
- **Every inbound announce is Zod-parsed**
(`GunDBPeerAnnounceSchema`) AND signature-verified
(`verifyAnnounceSignature` over the canonical body).
- **Constant-time peer-ID compare** via `constantTimeEqual` from
`@suaveplan/crypto-utils` — short-circuiting byte-level compares
would leak a side channel.
- Typed `EventBusLike<GunDBEventMap>` integration: `started`,
`peer-discovered`, `peer-lost`, `relay-error`, `stopped`.
- Optional `@suaveplan/service-discovery` `ServiceRegistry`
integration — peers auto-register on join, auto-deregister on
drop and on `stop()`/`dispose()`.
- OTel instrumentation: `disc_gundb.start` span; counters
`disc_gundb.peers_discovered_total{outcome}`,
`gundb.invalid_messages_total{reason}`,
`gundb.signature_rejected_total{reason}`,
`disc_gundb.relay_errors_total{relay}`; histogram
`gundb_announce_duration_ms`.
- `AbortSignal` honoured on every async public method.
- Peer cap (`maxPeers`, default `100`) enforced at admission.
- Spoofing guard: an inbound announce whose `peerId` does not match
the GunDB record key it was published under is rejected as
`wrong-kind`.
## Usage: Basic Example
```ts
import { GunDBDiscovery } from "@suaveplan/network-discovery-gundb";
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const discovery = new GunDBDiscovery({
peers: ["wss://my-private-relay.example.com/gun"],
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[] }) as unknown as GunNodeLike;
},
localPeerId: "peer-local",
signingKey,
});
await discovery.start();
const peers = await discovery.discover(5_000);
console.log(`found ${peers.length} peers`);
await discovery.stop();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import {
GunDBDiscovery,
publicKeyHex,
type GunDBEventMap,
} from "@suaveplan/network-discovery-gundb";
const bus = new EventBus<GunDBEventMap>();
const registry = new ServiceRegistry();
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const localPeerId = publicKeyHex(signingKey);
bus.on("gundb:peer-discovered", ({ peer }) => {
console.log("peer joined:", peer.id, peer.addresses);
});
bus.on("gundb:relay-error", ({ reason }) => {
console.warn("relay:", reason);
});
const discovery = new GunDBDiscovery({
peers: [
"wss://relay-a.example.com/gun",
"wss://relay-b.example.com/gun",
],
room: "my-app/v1",
maxPeers: 256,
announceIntervalMs: 15_000,
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({
peers: cfg.peers as string[],
localStorage: cfg.localStorage,
}) as unknown as GunNodeLike;
},
localPeerId,
signingKey,
bus,
registry,
});
const chain = new DiscoveryChain([discovery]);
const peers = await chain.discover(5_000);
```
## API Reference
- `new GunDBDiscovery(config, clock?)` — Zod-validates the config,
enforces a callable `gunFactory`, stores the resolved
configuration. Throws `NET_DISC_GUNDB.INVALID_OPTIONS` on
validation / missing factory / wrong-length signing key.
- `start(signal?)` — instantiates the Gun root via the factory,
scopes the peers node by room, subscribes to the room's children,
emits `started`. Throws `RELAY_FAILED` (with the underlying error
as `cause`) on factory failure; throws `DISPOSED` if disposed;
rethrows abort-error if signalled.
- `stop(signal?)` — idempotent. Detaches every subscription,
deregisters every peer from the registry, clears local state.
Emits `stopped` when the call wasn't aborted.
- `discover(timeoutMs, signal?)` — returns the current peer
snapshot mapped into `network-bootstrap` `PeerInfo[]`. Honours
signal up-front.
- `isAvailable(signal?)` — `true` only while running and not
disposed.
- `announce(peerInfo, signal?)` — signs a fresh
`GunDBPeerAnnounce` over `localPeerId` and publishes it into the
room. Also mirrors the local peer into the optional
`ServiceRegistry`. Throws `DISPOSED` / `NOT_RUNNING` when
appropriate; throws `RELAY_FAILED` if `put` rejects.
- `getPeers()` / `getPeerCount()` / `isRunning()` — synchronous
introspection.
- `dispose()` — permanent teardown; subsequent lifecycle calls
throw `DISPOSED`.
## Implementation Notes
- Inbound dispatch is a four-step pipeline: (1) skip-if-local
(constant-time compare); (2) Zod parse; (3) signature verify;
(4) admit / update / cap. Each step's failure mode has a
dedicated counter label so dashboards can separate protocol
malformations from cryptographic ones from spoofing attempts.
- `peerIdEquals` is the constant-time comparator. When the two
inputs differ in length, it pads to equal length and still calls
`constantTimeEqual` so the wall-clock cost is independent of the
shorter input.
- `classifyParseFailure` directly inspects the raw payload to tell
apart "unsigned" (missing `signature` / `signaturePublicKey`)
from "wrong-kind" (explicit non-`peer-announce` discriminator)
from "malformed" (anything else). This ensures the
signature_rejected counter increments correctly even when Zod's
failure path collapses both into a single rejection.
- The strategy never throws out of the inbound handler — a
malicious gossip neighbour must not be able to crash a node by
publishing garbage. Failures surface only via counters and the
optional `gundb:relay-error` bus event.
- The factory is `async`-tolerant so production code can
dynamically `import("gun")` (a Node-flavoured package with
`window` side-effects) without forcing this package itself to
bundle it.
- `dispose()` is idempotent; subsequent lifecycle calls throw
`DISPOSED`. `stop()` plus `dispose()` is the canonical shutdown
sequence, but `dispose()` alone is safe (it implies `stop()`).
- The `ServiceRegistry` registration id form is
`gundb:<room>:<peerId>` mirroring the WebTorrent / Trystero
family so a single `ServiceRegistry` watcher can see peers from
every discovery family without colliding.

View File

@@ -0,0 +1,456 @@
import { constantTimeEqual } from "@suaveplan/crypto-utils";
import type { DiscoveryStrategy, PeerInfo } from "@suaveplan/network-bootstrap";
import type { Clock } from "@suaveplan/types";
import {
NET_DISC_GUNDB_CODES,
NetDiscGunDBError,
} from "../../errors/errors.js";
import {
recordAnnounceDuration,
recordInvalidMessage,
recordPeersDiscovered,
recordRelayError,
recordSignatureRejection,
withStartSpan,
} from "../../otel/otel.js";
import {
signAnnounce,
verifyAnnounceSignature,
} from "../../signing/signing.js";
import type {
GunDBDiscoveryConfig,
GunDBPeerAnnounce,
GunDBPeerMetadata,
GunNodeLike,
ResolvedGunDBDiscoveryConfig,
} from "../../types/types.js";
import {
GunDBDiscoveryConfigSchema,
GunDBPeerAnnounceSchema,
} from "../../types/types.schemas.js";
const defaultClock: Clock = () => Date.now();
const encoder = new TextEncoder();
/**
* Constant-time equality over the UTF-8 encoded form of two peer ids.
* Used to compare a candidate incoming peer-id against the local peer-id
* without leaking timing information — short-circuiting byte-level
* compares are a side-channel.
*/
function peerIdEquals(a: string, b: string): boolean {
const ba = encoder.encode(a);
const bb = encoder.encode(b);
if (ba.length !== bb.length) {
// Pad to equal length with a fresh comparison so the timing
// remains independent of the shorter input.
const max = Math.max(ba.length, bb.length);
const padA = new Uint8Array(max);
const padB = new Uint8Array(max);
padA.set(ba);
padB.set(bb);
// Run constantTimeEqual to keep one consistent code path; the
// length-mismatch already determines the answer.
constantTimeEqual(padA, padB);
return false;
}
return constantTimeEqual(ba, bb);
}
/**
* Browser-tier GunDB discovery strategy implementing the
* `@suaveplan/network-bootstrap` `DiscoveryStrategy` contract. Wraps a
* single Gun instance (constructed via the injected factory) and
* exposes a deduped peer set to bootstrap chains.
*
* The `peers` (relay) list is REQUIRED and non-empty — there is no
* implicit public-relay fallback. Construction throws
* `INVALID_OPTIONS` on missing / empty relays, missing factory, or a
* non-32-byte `signingKey`.
*
* Every outbound announce is Ed25519-signed by the local peer.
* Every inbound announce is Zod-validated AND Ed25519-verified before
* being admitted into the local peer set. Failures are silently
* dropped + counted; an attacker cannot crash a node by gossiping
* garbage.
*/
export class GunDBDiscovery implements DiscoveryStrategy {
public readonly name = "gundb";
public readonly config: ResolvedGunDBDiscoveryConfig;
private readonly clock: Clock;
private readonly peers = new Map<string, GunDBPeerMetadata>();
private peersNode: GunNodeLike | null = null;
private running = false;
private disposed = false;
public constructor(
rawConfig: GunDBDiscoveryConfig,
clock: Clock = defaultClock
) {
const parsed = GunDBDiscoveryConfigSchema.safeParse(rawConfig);
if (!parsed.success) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.INVALID_OPTIONS,
"errors.gundb.invalid_options",
{ cause: parsed.error }
);
}
// Zod treats `unknown` as "any value accepted" — defend
// against an explicit `undefined` / non-callable factory.
if (typeof rawConfig.gunFactory !== "function") {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.INVALID_OPTIONS,
"errors.gundb.factory_required"
);
}
this.clock = clock;
this.config = {
peers: parsed.data.peers,
room: parsed.data.room,
localStorage: parsed.data.localStorage,
announceIntervalMs: parsed.data.announceIntervalMs,
maxPeers: parsed.data.maxPeers,
peerTtlMs: parsed.data.peerTtlMs,
gunFactory: rawConfig.gunFactory,
localPeerId: parsed.data.localPeerId,
signingKey: parsed.data.signingKey,
bus: rawConfig.bus,
registry: rawConfig.registry,
};
}
public async start(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.DISPOSED,
"errors.gundb.disposed"
);
}
if (this.running) return;
await withStartSpan(async (span) => {
span.setAttribute("room", this.config.room);
span.setAttribute("relay_count", this.config.peers.length);
let instance: GunNodeLike;
try {
instance = await this.config.gunFactory({
peers: this.config.peers,
localStorage: this.config.localStorage,
});
} catch (err) {
this.emitRelayError(undefined, asError(err).message);
recordRelayError(undefined);
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.RELAY_FAILED,
"errors.gundb.relay_failed",
{ cause: asError(err) }
);
}
if (signal?.aborted) {
throw toAbortError(signal);
}
this.peersNode = instance.get(
`discovery:${this.config.room}:peers`
);
this.subscribePeers(this.peersNode);
this.running = true;
this.config.bus?.emit("gundb:started", {
room: this.config.room,
relayCount: this.config.peers.length,
at: this.clock(),
});
});
}
public async stop(signal?: AbortSignal): Promise<void> {
if (!this.running) return;
this.running = false;
if (signal?.aborted) {
this.teardown();
return;
}
this.teardown();
this.config.bus?.emit("gundb:stopped", { at: this.clock() });
}
public async discover(
_timeoutMs: number,
signal?: AbortSignal
): Promise<PeerInfo[]> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.DISPOSED,
"errors.gundb.disposed"
);
}
const out: PeerInfo[] = [];
for (const m of this.peers.values()) {
out.push({
peerId: m.id,
addresses: [...m.addresses],
lastSeen: m.lastSeen,
source: this.name,
});
}
return out;
}
public async isAvailable(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) throw toAbortError(signal);
return this.running && !this.disposed;
}
public async announce(
peerInfo: PeerInfo,
signal?: AbortSignal
): Promise<void> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.DISPOSED,
"errors.gundb.disposed"
);
}
if (!this.running || this.peersNode === null) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.NOT_RUNNING,
"errors.gundb.not_running"
);
}
const t0 = this.clock();
const signed = signAnnounce(this.config.signingKey, {
kind: "peer-announce",
peerId: this.config.localPeerId,
addresses: [...peerInfo.addresses],
metadata: {},
capabilities: [],
lastSeen: t0,
});
try {
this.peersNode.get(this.config.localPeerId).put(signed);
} catch (err) {
recordRelayError(undefined);
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.RELAY_FAILED,
"errors.gundb.relay_failed",
{ cause: asError(err) }
);
}
recordAnnounceDuration(this.clock() - t0);
// Mirror local peer into the optional registry.
this.config.registry?.register({
id: this.registryId(this.config.localPeerId),
name: "gundb-discovery",
address: peerInfo.addresses[0] ?? this.config.localPeerId,
metadata: {
source: this.name,
room: this.config.room,
addresses: [...peerInfo.addresses],
},
});
}
/** Local peer count. */
public getPeerCount(): number {
return this.peers.size;
}
/** Snapshot of the live peer set. */
public getPeers(): readonly GunDBPeerMetadata[] {
return Array.from(this.peers.values());
}
/** Whether `start()` has completed and `stop()`/`dispose()` have not. */
public isRunning(): boolean {
return this.running;
}
/** Dispose every resource. After `dispose()` the strategy is unusable. */
public dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.running = false;
this.teardown();
}
/** Subscribe to the room peer-set; dispatch into the inbound handler. */
private subscribePeers(node: GunNodeLike): void {
node.map().on((data: unknown, key: string) => {
this.handleIncomingPayload(data, key);
});
}
/**
* Validate + verify an inbound wire payload and either admit it to
* the local peer set or silently drop it with the appropriate
* counter increment. Never throws.
*/
private handleIncomingPayload(data: unknown, key: string): void {
if (!this.running) return;
// Skip the local peer's own record — gossip will echo it back.
if (peerIdEquals(key, this.config.localPeerId)) return;
// Step 1: shape validation via Zod.
const parsed = GunDBPeerAnnounceSchema.safeParse(data);
if (!parsed.success) {
// Distinguish "no signature fields" (UNSIGNED_ANNOUNCE) from
// "malformed" (INVALID_WIRE_PAYLOAD / wrong-kind).
const reason = classifyParseFailure(data);
recordInvalidMessage(reason);
if (reason === "unsigned") {
recordSignatureRejection("unsigned");
}
this.emitRelayError(undefined, `invalid wire payload: ${reason}`);
return;
}
const announce: GunDBPeerAnnounce = parsed.data;
// Step 2: cryptographic verification.
if (!verifyAnnounceSignature(announce)) {
recordInvalidMessage("bad-signature");
recordSignatureRejection("bad-signature");
this.emitRelayError(undefined, "bad signature on inbound announce");
return;
}
// Step 3: ensure the announce's peerId matches the GunDB key
// it was published under (constant-time compare). Mismatches
// are a spoofing attempt — treat as INVALID_WIRE_PAYLOAD.
if (!peerIdEquals(announce.peerId, key)) {
recordInvalidMessage("wrong-kind");
this.emitRelayError(undefined, "peerId mismatch");
return;
}
// Step 4: admit. Update lastSeen if the peer is already known;
// otherwise insert and emit `peer-discovered` (respecting
// `maxPeers`).
const existing = this.peers.get(announce.peerId);
if (existing !== undefined) {
existing.lastSeen = announce.lastSeen;
return;
}
if (this.peers.size >= this.config.maxPeers) {
recordPeersDiscovered("rejected");
return;
}
const metadata: GunDBPeerMetadata = {
id: announce.peerId,
addresses: [...announce.addresses],
metadata: { ...announce.metadata },
capabilities: [...announce.capabilities],
connectedAt: this.clock(),
lastSeen: announce.lastSeen,
};
this.peers.set(announce.peerId, metadata);
recordPeersDiscovered("found");
this.config.bus?.emit("gundb:peer-discovered", {
peer: metadata,
at: this.clock(),
});
this.config.registry?.register({
id: this.registryId(announce.peerId),
name: "gundb-discovery",
address: metadata.addresses[0] ?? announce.peerId,
metadata: {
source: this.name,
room: this.config.room,
addresses: [...metadata.addresses],
},
});
}
private emitRelayError(relay: string | undefined, reason: string): void {
this.config.bus?.emit("gundb:relay-error", {
relay,
reason,
at: this.clock(),
});
}
private teardown(): void {
const node = this.peersNode;
this.peersNode = null;
if (node !== null) {
try {
node.off();
} catch {
/* swallow */
}
}
if (this.config.registry !== undefined) {
for (const id of this.peers.keys()) {
try {
this.config.registry.deregister(this.registryId(id));
} catch {
/* swallow */
}
}
try {
this.config.registry.deregister(
this.registryId(this.config.localPeerId)
);
} catch {
/* swallow */
}
}
this.peers.clear();
}
private registryId(peerId: string): string {
return `gundb:${this.config.room}:${peerId}`;
}
}
/**
* Distinguish "unsigned" announces from other malformed shapes.
* Inspect the raw payload directly so a Zod failure on `signature` /
* `signaturePublicKey` (missing or wrong type) classifies as
* `"unsigned"`, while every other failure classifies as
* `"malformed"` or `"wrong-kind"`.
*/
function classifyParseFailure(
data: unknown
): "unsigned" | "wrong-kind" | "malformed" {
if (data === null || typeof data !== "object") return "malformed";
const obj = data as Record<string, unknown>;
if ("kind" in obj && obj["kind"] !== "peer-announce") return "wrong-kind";
const sig = obj["signature"];
const pub = obj["signaturePublicKey"];
if (
typeof sig !== "string" ||
sig.length === 0 ||
typeof pub !== "string" ||
pub.length === 0
) {
return "unsigned";
}
return "malformed";
}
function asError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(typeof value === "string" ? value : String(value));
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

View File

@@ -0,0 +1 @@
export { GunDBDiscovery } from "./gundb-discovery.js";

View File

@@ -0,0 +1,108 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Structured error registry for `@suaveplan/network-discovery-gundb`.
Registers eight typed codes via `defineCodes("NET_DISC_GUNDB", ...)` from
`@suaveplan/error`. The `NetDiscGunDBError` class extends
`SuaveplanError<NetDiscGunDBErrorCode>` so every error thrown out of the
package carries a stable `code` consumers can branch on, plus a
canonical `cause` chain and the i18n message-key contract used across
Suaveplan.
The wire-payload rejection codes (`UNSIGNED_ANNOUNCE`, `BAD_SIGNATURE`,
`INVALID_WIRE_PAYLOAD`) are deliberately **not** thrown out of the
inbound-message handler — a malicious peer must not be able to crash a
node by gossiping garbage. They are surfaced via the OTel
`gundb.invalid_messages_total{reason}` /
`gundb.signature_rejected_total{reason}` counters and the optional
`gundb:relay-error` bus event instead. The codes are still defined here
so callers can construct them in tests and so the reason labels stay
synchronized between the counters and the error registry.
## Features
- Eleven stable codes covering every public-API failure path plus the
three wire-payload drop reasons.
- `NetDiscGunDBError` extends `SuaveplanError<NetDiscGunDBErrorCode>` —
inherits `code`, `cause`, `messageKey`, and `.name`.
- Frozen const-tuple input to `defineCodes` so consumer code branching
on `error.code` gets full IntelliSense.
- `NetDiscGunDBErrorCode` value type derived from the const-tuple.
## Usage: Basic Example
```ts
import {
NET_DISC_GUNDB_CODES,
NetDiscGunDBError,
} from "@suaveplan/network-discovery-gundb";
try {
await discovery.start();
} catch (err) {
if (
err instanceof NetDiscGunDBError &&
err.code === NET_DISC_GUNDB_CODES.RELAY_FAILED
) {
// every relay was unreachable — fall through to the next
// strategy in the chain
}
}
```
## Usage: Advanced Example
```ts
import {
NET_DISC_GUNDB_CODES,
NetDiscGunDBError,
} from "@suaveplan/network-discovery-gundb";
function classify(err: unknown): string {
if (!(err instanceof NetDiscGunDBError)) return "unknown";
switch (err.code) {
case NET_DISC_GUNDB_CODES.RELAY_FAILED:
return "relay-unreachable";
case NET_DISC_GUNDB_CODES.DISPOSED:
return "post-dispose-call";
case NET_DISC_GUNDB_CODES.NOT_RUNNING:
return "before-start";
case NET_DISC_GUNDB_CODES.INVALID_OPTIONS:
return "bad-config";
case NET_DISC_GUNDB_CODES.PEER_CONNECT_FAILED:
return "peer-rejected";
default:
return "other";
}
}
```
## API Reference
- `NET_DISC_GUNDB_CODES` — frozen object exposing each code as both a
property key and a string value. Generated via `defineCodes`.
- `NetDiscGunDBErrorCode` — string-literal union of every code value.
- `NetDiscGunDBError` — concrete error class.
- `code: NetDiscGunDBErrorCode`
- `messageKey: string` — i18n key (e.g. `"errors.gundb.relay_failed"`).
- `cause?: unknown` — original error / structured info preserved.
- `name: "NetDiscGunDBError"` — for stack traces and `instanceof`-light
branch logic.
## Implementation Notes
- All public-API entry points use the same `NetDiscGunDBError` class —
consumers do not have to discriminate by `name`.
- `UNSIGNED_ANNOUNCE` / `BAD_SIGNATURE` / `INVALID_WIRE_PAYLOAD` are
listed in the registry so the counter `reason` labels and the error
codes use one canonical set of strings, which keeps the dashboards
aligned with the error taxonomy. They are not thrown out of the
message handler — see file-level comment.
- `defineCodes` enforces the `NET_DISC_GUNDB.` prefix at the registry
level so two packages can't collide on a bare code value.
- The class explicitly sets `this.name = "NetDiscGunDBError"` so
uglifier rename-passes don't corrupt the runtime brand.

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_DISC_GUNDB_CODES, NetDiscGunDBError } from "./errors.js";
describe("NetDiscGunDBError", () => {
it("registers exactly the documented codes", { timeout: 5000 }, () => {
expect(Object.keys(NET_DISC_GUNDB_CODES).sort()).toEqual(
[
"BAD_SIGNATURE",
"DISPOSED",
"INVALID_OPTIONS",
"INVALID_WIRE_PAYLOAD",
"NOT_RUNNING",
"PEER_CONNECT_FAILED",
"PEER_NOT_CONNECTED",
"RELAY_FAILED",
"SEND_FAILED",
"TRANSPORT_CLOSED",
"UNSIGNED_ANNOUNCE",
].sort()
);
});
it("preserves code, name, and cause", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.RELAY_FAILED,
"errors.gundb.relay_failed",
{ cause }
);
expect(err.code).toBe(NET_DISC_GUNDB_CODES.RELAY_FAILED);
expect(err.name).toBe("NetDiscGunDBError");
expect(err.cause).toBe(cause);
expect(err instanceof NetDiscGunDBError).toBe(true);
});
it("constructs without options", { timeout: 5000 }, () => {
const err = new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.INVALID_OPTIONS,
"errors.gundb.invalid_options"
);
expect(err.cause).toBeUndefined();
expect(err.code).toBe(NET_DISC_GUNDB_CODES.INVALID_OPTIONS);
});
});

View File

@@ -0,0 +1,67 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error codes emitted by `@suaveplan/network-discovery-gundb`.
*
* - `INVALID_OPTIONS` — caller-supplied config failed Zod validation, the
* `peers` list was missing or empty, the required `gunFactory` was
* missing, or `signingKey` was not a 32-byte `Uint8Array`.
* - `DISPOSED` — operation attempted after `dispose()` permanently
* cleared state.
* - `NOT_RUNNING` — operation attempted before `start()` completed (or
* after `stop()` cleared state).
* - `RELAY_FAILED` — every relay in the configured list failed, or the
* underlying `Gun` factory rejected.
* - `PEER_CONNECT_FAILED` — the gossip transport surfaced a peer record
* that could not be admitted to the local set.
* - `UNSIGNED_ANNOUNCE` — an inbound announce was missing `signature` or
* `signaturePublicKey`. Silently dropped; counter incremented.
* - `BAD_SIGNATURE` — an inbound announce's Ed25519 verification failed.
* Silently dropped; counter incremented.
* - `INVALID_WIRE_PAYLOAD` — an inbound payload failed Zod validation
* (malformed JSON, missing fields, wrong `kind`, etc.). Silently
* dropped; counter incremented.
*/
export const NET_DISC_GUNDB_CODES = defineCodes("NET_DISC_GUNDB", [
"INVALID_OPTIONS",
"DISPOSED",
"NOT_RUNNING",
"RELAY_FAILED",
"PEER_CONNECT_FAILED",
"UNSIGNED_ANNOUNCE",
"BAD_SIGNATURE",
"INVALID_WIRE_PAYLOAD",
"TRANSPORT_CLOSED",
"PEER_NOT_CONNECTED",
"SEND_FAILED",
] as const);
export type NetDiscGunDBErrorCode =
(typeof NET_DISC_GUNDB_CODES)[keyof typeof NET_DISC_GUNDB_CODES];
/**
* Structured error class for the GunDB discovery strategy. Every public
* boundary throws this on failure; consumers branch on `error.code`.
*
* The wire-payload rejection codes (`UNSIGNED_ANNOUNCE`,
* `BAD_SIGNATURE`, `INVALID_WIRE_PAYLOAD`) are also used as
* `reason`-style labels on the
* `gundb.invalid_messages_total{reason}` / `signature_rejected_total`
* counters — but they are NOT thrown out of the orchestrator's inbound
* message handler, because a malicious peer must not be able to crash
* a node by sending garbage. They are surfaced only via the counter.
*/
export class NetDiscGunDBError extends SuaveplanError<NetDiscGunDBErrorCode> {
public constructor(
code: NetDiscGunDBErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetDiscGunDBError";
}
}

View File

@@ -0,0 +1,72 @@
// Core
export { GunDBDiscovery } from "./core/gundb-discovery/gundb-discovery.js";
// Errors
export {
NET_DISC_GUNDB_CODES,
NetDiscGunDBError,
type NetDiscGunDBErrorCode,
} from "./errors/errors.js";
// OTel
export {
type InvalidMessageReason as OTelInvalidMessageReason,
type PeerDiscoveryOutcome,
recordAnnounceDuration,
recordInvalidMessage,
recordPeersDiscovered,
recordRelayError,
recordSignatureRejection,
type SignatureRejectionReason,
withStartSpan,
} from "./otel/otel.js";
// Plugin
export { GunDBPlugin } from "./plugin/plugin.js";
// Signing
export {
canonicalSerializeAnnounceBody,
publicKeyHex,
signAnnounce,
verifyAnnounceSignature,
} from "./signing/signing.js";
export {
canonicalSerializeEnvelope,
type SignedEnvelopeWire,
signEnvelope,
verifyEnvelopeSignature,
} from "./transport/envelope-signing.js";
// Transport
export {
GunDBTransport,
type GunDBTransportConfig,
} from "./transport/transport.js";
// Types
export type {
GunDBDiscoveredEvent,
GunDBDiscoveryConfig,
GunDBEventMap,
GunDBLostEvent,
GunDBMessage,
GunDBPeerAnnounce,
GunDBPeerAnnounceBody,
GunDBPeerMetadata,
GunDBRelayErrorEvent,
GunDBStartedEvent,
GunDBStoppedEvent,
GunFactory,
GunFactoryConfig,
GunNodeLike,
HexEd25519PublicKey,
HexEd25519Signature,
InvalidMessageReason,
ResolvedGunDBDiscoveryConfig,
} from "./types/types.js";
// Schemas
export {
GunDBDiscoveryConfigSchema,
GunDBMessageSchema,
GunDBPeerAnnounceBodySchema,
GunDBPeerAnnounceSchema,
GunDBPeerMetadataSchema,
} from "./types/types.schemas.js";

View File

@@ -0,0 +1,115 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry surface for `@suaveplan/network-discovery-gundb`. Exposes
one span wrapper (`withStartSpan`), four counters
(`disc_gundb.peers_discovered_total`,
`gundb.invalid_messages_total`, `gundb.signature_rejected_total`,
`disc_gundb.relay_errors_total`), and one histogram
(`gundb_announce_duration_ms`). All instruments resolve lazily via the
global OTel API so the package works with or without an SDK
registered.
The `gundb.invalid_messages_total{reason}` counter and the
`gundb.signature_rejected_total{reason}` counter are the
**security-observability lever** for the strategy — they are the only
external evidence of a misbehaving peer because the orchestrator
deliberately swallows inbound-message exceptions to keep nodes alive
under adversarial conditions. Operators MUST alert on non-zero rates
here.
## Features
- `withStartSpan(fn)` — wraps `start()` in `disc_gundb.start` with
guaranteed `span.end()` even on rejection. Attributes such as
`room` and `relay_count` are set by the caller inside the body.
- `recordPeersDiscovered(outcome, count?)` — increments
`disc_gundb.peers_discovered_total{outcome}`. Guards against
zero / negative counts.
- `recordInvalidMessage(reason)` — increments
`gundb.invalid_messages_total{reason}` where reason is one of the
literal labels `"malformed"`, `"wrong-kind"`, `"unsigned"`,
`"bad-signature"`.
- `recordSignatureRejection(reason)` — increments
`gundb.signature_rejected_total{reason}` where reason is
`"unsigned"` or `"bad-signature"`.
- `recordRelayError(url?)` — increments
`disc_gundb.relay_errors_total{relay}` with `"unknown"` fallback.
- `recordAnnounceDuration(ms)` — records a value into
`gundb_announce_duration_ms`. Negative inputs clamp to zero.
## Usage: Basic Example
```ts
import {
recordPeersDiscovered,
recordInvalidMessage,
} from "@suaveplan/network-discovery-gundb";
recordPeersDiscovered("found");
recordInvalidMessage("bad-signature");
```
## Usage: Advanced Example
```ts
import {
withStartSpan,
recordPeersDiscovered,
recordInvalidMessage,
recordSignatureRejection,
recordRelayError,
recordAnnounceDuration,
} from "@suaveplan/network-discovery-gundb";
await withStartSpan(async (span) => {
span.setAttribute("room", "my-app/v1");
span.setAttribute("relay_count", 2);
const start = performance.now();
try {
// ... publish announce ...
recordAnnounceDuration(performance.now() - start);
recordPeersDiscovered("found", 3);
} catch (err) {
recordRelayError("wss://relay.example.com/gun");
throw err;
}
});
// On the inbound side: caller dispatches a single rejection per
// dropped message.
recordInvalidMessage("wrong-kind");
recordSignatureRejection("unsigned");
recordSignatureRejection("bad-signature");
```
## API Reference
- `withStartSpan<T>(fn: (span) => Promise<T>): Promise<T>` — runs
`fn` inside a `disc_gundb.start` span; ends the span on both
resolution and rejection.
- `recordPeersDiscovered(outcome: "found" | "duplicate" | "rejected", count?: number): void`.
- `recordInvalidMessage(reason: "malformed" | "wrong-kind" | "unsigned" | "bad-signature"): void`.
- `recordSignatureRejection(reason: "unsigned" | "bad-signature"): void`.
- `recordRelayError(relay?: string): void` — `"unknown"` when omitted.
- `recordAnnounceDuration(ms: number): void` — negative clamped to 0.
## Implementation Notes
- Uses `@opentelemetry/api` only — no SDK lock-in. With no SDK
registered, every call is a no-op via the OTel noop
meter/tracer.
- The `reason` labels on both counters are deliberately bounded — the
package will never emit a free-form `reason` value, which prevents
cardinality blow-up on the metrics backend.
- `signature_rejected_total` and `invalid_messages_total` are kept
separate so dashboards can tell apart "garbage payload" from "valid
shape but bad crypto". An attacker probing the signature path
shows up only on the second counter.
- `gundb_announce_duration_ms` is a duration histogram; the bucket
layout is whatever the exporter / view configures.

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
recordAnnounceDuration,
recordInvalidMessage,
recordPeersDiscovered,
recordRelayError,
recordSignatureRejection,
recordTransportActivePeers,
recordTransportMessage,
withStartSpan,
withTransportConnectSpan,
withTransportDisconnectSpan,
withTransportSendSpan,
} from "./otel.js";
describe("otel surface", () => {
it("withStartSpan resolves and propagates errors", {
timeout: 5000,
}, async () => {
await expect(withStartSpan(async () => 42)).resolves.toBe(42);
await expect(
withStartSpan(async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
});
it("counters and histograms accept calls with no SDK", {
timeout: 5000,
}, () => {
recordPeersDiscovered("found", 3);
recordPeersDiscovered("duplicate");
recordPeersDiscovered("rejected", 0);
recordPeersDiscovered("rejected", -5);
recordInvalidMessage("malformed");
recordInvalidMessage("wrong-kind");
recordInvalidMessage("unsigned");
recordInvalidMessage("bad-signature");
recordSignatureRejection("unsigned");
recordSignatureRejection("bad-signature");
recordRelayError("wss://relay.example.com/gun");
recordRelayError(undefined);
recordAnnounceDuration(125);
recordAnnounceDuration(-1);
recordTransportMessage("inbound", "ok");
recordTransportMessage("outbound", "error");
recordTransportActivePeers(1);
recordTransportActivePeers(-1);
recordTransportActivePeers(0);
expect(true).toBe(true);
});
it("transport span wrappers resolve and propagate errors", {
timeout: 5000,
}, async () => {
await expect(withTransportConnectSpan(async () => 1)).resolves.toBe(1);
await expect(withTransportDisconnectSpan(async () => 2)).resolves.toBe(
2
);
await expect(withTransportSendSpan(async () => 3)).resolves.toBe(3);
await expect(
withTransportSendSpan(async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
});
});

View File

@@ -0,0 +1,218 @@
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
const TRACER_NAME = "@suaveplan/network-discovery-gundb";
const METER_NAME = "@suaveplan/network-discovery-gundb";
const tracer: Tracer = trace.getTracer(TRACER_NAME);
const meter = metrics.getMeter(METER_NAME);
/**
* Counter incremented for every peer the strategy emits, labelled by
* the outcome of the discovery attempt.
*/
const peersDiscoveredCounter = meter.createCounter(
"disc_gundb.peers_discovered_total",
{
description:
"Total peers seen by the GunDB discovery strategy, by outcome.",
}
);
/**
* Counter incremented every time an inbound wire payload is silently
* dropped, labelled by the reason. This is the **security-observability
* lever** — any spike on `reason="bad-signature"` or
* `reason="unsigned"` indicates an active misbehaving peer or relay.
*/
const invalidMessagesCounter = meter.createCounter(
"gundb.invalid_messages_total",
{
description:
"Wire payloads silently dropped because they failed Zod validation or were missing required fields.",
}
);
/**
* Counter incremented every time an inbound announce is dropped due to
* signature problems. Tracked separately from the generic
* invalid-messages counter so dashboards can distinguish protocol
* malformations (`INVALID_WIRE_PAYLOAD`) from cryptographic ones
* (`UNSIGNED_ANNOUNCE` / `BAD_SIGNATURE`).
*/
const signatureRejectedCounter = meter.createCounter(
"gundb.signature_rejected_total",
{
description:
"Inbound announces dropped because they were unsigned or carried a bad signature.",
}
);
/**
* Counter incremented for every relay-level warning or failure
* surfaced by the underlying Gun instance, labelled by relay URL when
* known.
*/
const relayErrorsCounter = meter.createCounter(
"disc_gundb.relay_errors_total",
{
description:
"Relay warnings / failures emitted by the underlying Gun instance.",
}
);
/**
* Histogram of the wall-clock duration (ms) for each outbound
* announce publish. Useful for catching relay back-pressure.
*/
const announceDurationHistogram = meter.createHistogram(
"gundb_announce_duration_ms",
{
description:
"Wall-clock duration of each outbound peer-announce publish, in milliseconds.",
unit: "ms",
}
);
/** Wrap the `start()` body in an OTel span. */
export async function withStartSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan("disc_gundb.start", async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/** Outcome label for {@link recordPeersDiscovered}. */
export type PeerDiscoveryOutcome = "found" | "duplicate" | "rejected";
/** Increment the peers-discovered counter. */
export function recordPeersDiscovered(
outcome: PeerDiscoveryOutcome,
count = 1
): void {
if (count > 0) {
peersDiscoveredCounter.add(count, { outcome });
}
}
/** Reason label for {@link recordInvalidMessage}. */
export type InvalidMessageReason =
| "malformed"
| "wrong-kind"
| "unsigned"
| "bad-signature";
/** Increment the invalid-messages counter. */
export function recordInvalidMessage(reason: InvalidMessageReason): void {
invalidMessagesCounter.add(1, { reason });
}
/** Reason label for {@link recordSignatureRejection}. */
export type SignatureRejectionReason = "unsigned" | "bad-signature";
/** Increment the signature-rejected counter. */
export function recordSignatureRejection(
reason: SignatureRejectionReason
): void {
signatureRejectedCounter.add(1, { reason });
}
/** Increment the relay-errors counter. */
export function recordRelayError(relay: string | undefined): void {
relayErrorsCounter.add(1, { relay: relay ?? "unknown" });
}
/** Record the wall-clock duration of one outbound announce publish. */
export function recordAnnounceDuration(ms: number): void {
announceDurationHistogram.record(Math.max(0, ms));
}
/**
* Transport-tier counter: number of messages sent or received through the
* `GunDBTransport` adapter, labelled by direction (`inbound`/`outbound`)
* and outcome (`ok`/`error`).
*/
const transportMessagesCounter = meter.createCounter(
"gundb_transport.messages_total",
{
description:
"Messages sent or received through the GunDB transport adapter.",
}
);
/**
* Transport-tier active-peer gauge. Updated on every connect/disconnect
* and emitted as an `up-down counter` so dashboards see the live count.
*/
const transportActivePeersGauge = meter.createUpDownCounter(
"gundb_transport.active_peers",
{
description: "Currently connected transport peers.",
}
);
/** Wrap the transport `connect()` body in an OTel span. */
export async function withTransportConnectSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan("gundb_transport.connect", async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/** Wrap the transport `disconnect()` body in an OTel span. */
export async function withTransportDisconnectSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan(
"gundb_transport.disconnect",
async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
}
);
}
/** Wrap the transport `send()` body in an OTel span. */
export async function withTransportSendSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan("gundb_transport.send", async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/** Direction label for {@link recordTransportMessage}. */
export type TransportMessageDirection = "inbound" | "outbound";
/** Outcome label for {@link recordTransportMessage}. */
export type TransportMessageOutcome = "ok" | "error";
/** Increment the transport-message counter. */
export function recordTransportMessage(
direction: TransportMessageDirection,
outcome: TransportMessageOutcome
): void {
transportMessagesCounter.add(1, { direction, outcome });
}
/** Adjust the transport active-peers gauge by `delta` (+1 / -1). */
export function recordTransportActivePeers(delta: number): void {
if (delta === 0) return;
transportActivePeersGauge.add(delta);
}

View File

@@ -0,0 +1,84 @@
# GunDBPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GunDBPlugin` adapts `GunDBTransport` to the `NetworkPlugin` lifecycle that `@suaveplan/network` consumes. It is a thin three-method facade — `install` registers the transport with the node's router, `stop` closes the transport and unregisters it, `dispose` releases the node reference. There is no independent security surface; every wire-level operation is delegated to `GunDBTransport`, which signs every outbound envelope and verifies every inbound one. Treat the plugin as a registration shim, not a place to add new behaviour.
The plugin ships in the same package as the discovery strategy and the transport itself so a single signing key + Gun factory + relay set can power both peer discovery and message transport. Splitting the plugin into a separate package would have doubled the security surface for no design gain.
## Features
- Implements `NetworkPlugin` from `@suaveplan/network` (`install` / `start` / `stop` / `dispose`)
- Canonical plugin name `"gundb"`
- Constructs the transport eagerly so config errors surface at the plugin boundary
- `start()` is a no-op — Gun is opened lazily on first connect or send
- `stop()` calls `transport.close()` and unregisters from the node
- `dispose()` is idempotent
## Usage
### Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { NetworkNode } from "@suaveplan/network";
import {
GunDBPlugin,
publicKeyHex,
} from "@suaveplan/network-discovery-gundb";
const sk = ed25519.randomPrivateKey();
const node = new NetworkNode();
node.use(
new GunDBPlugin({
peers: ["https://relay.example.com/gun"],
gunFactory: myGunFactory,
localPeerId: publicKeyHex(sk),
signingKey: sk,
})
);
await node.start();
```
### Advanced Example
```ts
import { GunDBPlugin } from "@suaveplan/network-discovery-gundb";
const plugin = new GunDBPlugin({
peers,
gunFactory,
localPeerId,
signingKey: sk,
room: "suaveplan-transport",
});
const node = new NetworkNode();
node.use(plugin);
await node.start();
// Use the transport directly via the plugin handle.
plugin.transport.onMessage((peerId, envelope) => {
/* ... */
});
await node.stop();
```
## API Reference
- `new GunDBPlugin(config: GunDBTransportConfig)` — eagerly constructs the inner `GunDBTransport`. Throws `NetDiscGunDBError(INVALID_OPTIONS)` on bad config.
- `plugin.name: "gundb"`
- `plugin.transport: GunDBTransport` — public handle for tests + advanced wiring.
- `install(node): void` — registers the transport with the node's router.
- `start(): void` — no-op.
- `stop(): Promise<void>` — closes the transport and unregisters it.
- `dispose(): void` — releases the node reference; safe to call multiple times.
## Implementation Notes
- The plugin holds a single internal reference to the `NetworkNode` so `stop()` can call `unregisterTransport`. Reset to `null` in `dispose`.
- `stop()` is safe to call without an `install`: the optional-chained `this.node?.unregisterTransport` is a no-op when `node` is null. The transport itself is always closed.
- No security checks live here — they all live in `GunDBTransport`. Adding a "raw publish" or "skip-sign" API to this plugin would be a security regression.

View File

@@ -0,0 +1,120 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeGun } from "../../tests/setup/fake-gun.js";
import { publicKeyHex } from "../signing/signing.js";
import { GunDBPlugin } from "./plugin.js";
const RELAYS = ["https://relay.example.com/gun"];
function makeKey(): { sk: Uint8Array; id: string } {
const sk = ed25519.randomPrivateKey();
return { sk, id: publicKeyHex(sk) };
}
describe("GunDBPlugin (NET-01 / NET-04)", () => {
it("exposes the canonical plugin name", { timeout: 5000 }, () => {
const { sk, id } = makeKey();
const p = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
expect(p.name).toBe("gundb");
});
it("registers its transport with the node on install", {
timeout: 5000,
}, () => {
const { sk, id } = makeKey();
const node = new NetworkNode();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
node.use(plugin);
const transports = node.messageRouter.getTransports();
expect(transports.map((t) => t.id)).toContain("gundb");
});
it("start() is a no-op", { timeout: 5000 }, async () => {
const { sk, id } = makeKey();
const node = new NetworkNode();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
node.use(plugin);
await node.start();
expect(node.isStarted).toBe(true);
await node.stop();
});
it("stop() closes the transport and unregisters it from the node", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKey();
const node = new NetworkNode();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
node.use(plugin);
await node.start();
await node.stop();
expect(
node.messageRouter.getTransports().map((t) => t.id)
).not.toContain("gundb");
expect(plugin.transport.isClosed()).toBe(true);
});
it("dispose releases the node reference and is idempotent", {
timeout: 5000,
}, () => {
const { sk, id } = makeKey();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
const node = new NetworkNode();
plugin.install(node);
plugin.dispose();
plugin.dispose();
});
it("stop is safe to call when no node was installed", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKey();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
await plugin.stop();
expect(plugin.transport.isClosed()).toBe(true);
});
it("exposes the transport handle for advanced wiring", {
timeout: 5000,
}, () => {
const { sk, id } = makeKey();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
expect(plugin.transport.id).toBe("gundb");
});
});

View File

@@ -0,0 +1,49 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import {
GunDBTransport,
type GunDBTransportConfig,
} from "../transport/transport.js";
/**
* `NetworkPlugin` that exposes a `GunDBTransport` to the host
* `NetworkNode`. Lifecycle is a thin wrapper:
*
* - `install(node)` registers the transport with the node so the
* router can find it by id.
* - `start()` is a no-op — Gun is opened lazily on first connect/send.
* - `stop()` closes the transport and unregisters it.
* - `dispose()` releases the node reference; idempotent.
*
* The plugin does NOT introduce a separate security surface — it
* delegates every wire-level operation to `GunDBTransport`, which is
* the load-bearing place that signs + verifies envelopes. Bypassing
* the transport (e.g. by reaching into its private state) would
* weaken the cryptographic contract.
*/
export class GunDBPlugin implements NetworkPlugin {
public readonly name = "gundb";
public readonly transport: GunDBTransport;
private node: NetworkNode | null = null;
public constructor(config: GunDBTransportConfig) {
this.transport = new GunDBTransport(config);
}
public install(node: NetworkNode): void {
this.node = node;
node.registerTransport(this.transport);
}
public start(): void {
// Lazy connect — nothing to do.
}
public async stop(): Promise<void> {
await this.transport.close();
this.node?.unregisterTransport(this.transport.id);
}
public dispose(): void {
this.node = null;
}
}

View File

@@ -0,0 +1,156 @@
# signing
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Ed25519 signing + verification primitives for `GunDBPeerAnnounce`
payloads. Provides three exported helpers: `signAnnounce` builds a
signed announce from a private key + body; `verifyAnnounceSignature`
returns the cryptographic verdict over an inbound announce;
`publicKeyHex` derives the hex-encoded public key from a 32-byte
secret. Internally, both sign and verify funnel through one canonical
byte serializer (`canonicalSerializeAnnounceBody`) so the same
deterministic byte string is signed and verified — that is the entire
load-bearing invariant for the signature contract.
Every signed announce that crosses the gossip network MUST be
verifiable by the receiver. The signing key is the announcing peer's
own Ed25519 secret, and the verifying key is carried inside the
announce payload itself. This closes the trivial-spoofing hole
(anyone listening on the relay can no longer fabricate announces in
another peer's name) but does NOT establish higher-layer identity
trust — a separate trust-anchor mechanism is the consuming
application's responsibility.
## Features
- `signAnnounce(privateKey, body)` produces a complete
`GunDBPeerAnnounce` with both `signaturePublicKey` and `signature`
populated.
- `verifyAnnounceSignature(announce)` returns `true` iff the inbound
signature checks out over the canonical body. Returns `false` for
every other condition — malformed hex, wrong length, bad
signature.
- `canonicalSerializeAnnounceBody(body)` exposes the byte
representation that is signed / verified. Same bytes both
directions or the contract breaks.
- Deterministic JSON helper (`canonicalJSON`) sorts object keys
lexicographically at every level so two announce bodies with the
same logical content produce byte-identical canonical strings
regardless of runtime insertion order.
- Pure: depends only on `@suaveplan/crypto-curve` (Ed25519).
## Usage: Basic Example
```ts
import {
publicKeyHex,
signAnnounce,
} from "@suaveplan/network-discovery-gundb/signing";
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const peerId = publicKeyHex(signingKey); // hex-encoded Ed25519 pub key
const announce = signAnnounce(signingKey, {
kind: "peer-announce",
peerId,
addresses: ["/webrtc/p2p/local"],
metadata: { region: "eu-west" },
capabilities: ["webrtc", "datachannel"],
lastSeen: Date.now(),
});
```
## Usage: Advanced Example
```ts
import {
signAnnounce,
verifyAnnounceSignature,
canonicalSerializeAnnounceBody,
} from "@suaveplan/network-discovery-gundb/signing";
import { ed25519 } from "@suaveplan/crypto-curve";
const sk = ed25519.randomPrivateKey();
const announce = signAnnounce(sk, {
kind: "peer-announce",
peerId: "local",
addresses: [],
metadata: {},
capabilities: [],
lastSeen: 1_700_000_000_000,
});
// Round-trip
const ok = verifyAnnounceSignature(announce); // → true
// Tampering breaks verification.
const tampered: typeof announce = {
...announce,
lastSeen: announce.lastSeen + 1,
};
verifyAnnounceSignature(tampered); // → false
// Single-bit flip in the signature also breaks verification.
const flipped: typeof announce = {
...announce,
signature:
announce.signature.slice(0, -1) +
(announce.signature.endsWith("0") ? "1" : "0"),
};
verifyAnnounceSignature(flipped); // → false
// Caller-facing canonical bytes (rarely needed; verifier uses it internally).
canonicalSerializeAnnounceBody({
kind: "peer-announce",
peerId: "local",
addresses: [],
metadata: {},
capabilities: [],
lastSeen: 1,
});
```
## API Reference
- `canonicalSerializeAnnounceBody(body: GunDBPeerAnnounceBody): Uint8Array`
— deterministic UTF-8 byte representation of the announce body. Field
order is fixed; nested objects are key-sorted at every level.
- `signAnnounce(privateKey: Uint8Array, body: GunDBPeerAnnounceBody): GunDBPeerAnnounce`
— throws if `privateKey.length !== 32`. Computes the public key from
the private key and signs the canonical bytes.
- `verifyAnnounceSignature(announce: GunDBPeerAnnounce): boolean` —
`true` iff every signature condition holds. Never throws.
- `publicKeyHex(privateKey: Uint8Array): string` — hex-encoded
Ed25519 public key derived from a 32-byte secret. Throws on wrong
length.
- `_hex.{toHex,fromHex}` — internal hex codec re-exposed so tests can
build malformed inputs without `node:buffer`.
## Implementation Notes
- The canonical line format is intentionally not valid JSON so callers
cannot accidentally substitute `JSON.stringify(body)` and silently
produce a non-deterministic, sometimes-verifiable signature. The
delimiter `|` is illegal in `kind` / hex `peerId` values so there is
no escaping ambiguity.
- Hex encoding uses lowercase `0-9a-f`; the Zod schema accepts either
case but the canonical output we sign / verify normalises to
lowercase. This is irrelevant to the contract because the hex
strings are never round-tripped through the canonical body — they
live only on the wire envelope.
- `verifyAnnounceSignature` never throws because it must be safe to
call on arbitrary attacker-controlled input. Every parse step
catches and returns `false`. The orchestrator distinguishes
"unsigned" (missing fields, caught at Zod time) from "bad-signature"
(verify returns `false`) at the call site.
- `ed25519.verify` is the noble-backed implementation from
`@suaveplan/crypto-curve` and itself returns `false` for invalid
inputs (it already catches internally), so the verifier here is a
thin orchestrator over the curve primitive.
- `signAnnounce` deliberately recomputes the public key from the
private key — callers cannot supply a mismatched (private, public)
pair, which would otherwise let a peer publish announces signed
with one key but verifiable with a different one.

View File

@@ -0,0 +1,191 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import type {
GunDBPeerAnnounce,
GunDBPeerAnnounceBody,
} from "../types/types.js";
import {
_hex,
canonicalSerializeAnnounceBody,
publicKeyHex,
signAnnounce,
verifyAnnounceSignature,
} from "./signing.js";
const SK = ed25519.randomPrivateKey();
const baseBody: GunDBPeerAnnounceBody = {
kind: "peer-announce",
peerId: "local",
addresses: ["/webrtc/p2p/local"],
metadata: { region: "eu" },
capabilities: ["webrtc"],
lastSeen: 1_700_000_000_000,
};
describe("canonicalSerializeAnnounceBody", () => {
it("is deterministic across key-insertion order", {
timeout: 5000,
}, () => {
const a = canonicalSerializeAnnounceBody({
...baseBody,
metadata: { z: 1, a: 2 },
});
const b = canonicalSerializeAnnounceBody({
...baseBody,
metadata: { a: 2, z: 1 },
});
expect(a).toEqual(b);
});
it("handles nested objects, arrays, null, primitives", {
timeout: 5000,
}, () => {
const a = canonicalSerializeAnnounceBody({
...baseBody,
metadata: {
b: { y: [1, 2, null, "x"], x: false },
a: null,
c: 0,
},
});
const b = canonicalSerializeAnnounceBody({
...baseBody,
metadata: {
c: 0,
a: null,
b: { x: false, y: [1, 2, null, "x"] },
},
});
expect(a).toEqual(b);
});
});
describe("publicKeyHex", () => {
it("derives correct 64-char hex public key", { timeout: 5000 }, () => {
const hex = publicKeyHex(SK);
expect(hex.length).toBe(64);
expect(/^[0-9a-f]+$/.test(hex)).toBe(true);
});
it("throws on wrong-length input", { timeout: 5000 }, () => {
expect(() => publicKeyHex(new Uint8Array(16))).toThrow();
});
});
describe("signAnnounce / verifyAnnounceSignature", () => {
it("round-trip: signed announce verifies", { timeout: 5000 }, () => {
const a = signAnnounce(SK, baseBody);
expect(verifyAnnounceSignature(a)).toBe(true);
expect(a.signaturePublicKey.length).toBe(64);
expect(a.signature.length).toBe(128);
});
it("signAnnounce rejects wrong-length private key", {
timeout: 5000,
}, () => {
expect(() => signAnnounce(new Uint8Array(16), baseBody)).toThrow();
});
it("single-bit-flip in signature breaks verification", {
timeout: 5000,
}, () => {
const a = signAnnounce(SK, baseBody);
const lastChar = a.signature.slice(-1);
const flipped =
a.signature.slice(0, -1) + (lastChar === "0" ? "1" : "0");
const tampered: GunDBPeerAnnounce = { ...a, signature: flipped };
expect(verifyAnnounceSignature(tampered)).toBe(false);
});
it("body tampering breaks verification", { timeout: 5000 }, () => {
const a = signAnnounce(SK, baseBody);
const tampered: GunDBPeerAnnounce = {
...a,
lastSeen: a.lastSeen + 1,
};
expect(verifyAnnounceSignature(tampered)).toBe(false);
});
it("malformed hex signature returns false (never throws)", {
timeout: 5000,
}, () => {
const a = signAnnounce(SK, baseBody);
const malformed1: GunDBPeerAnnounce = {
...a,
signature: `zz${a.signature.slice(2)}`,
};
const malformed2: GunDBPeerAnnounce = {
...a,
signaturePublicKey: "zzz",
};
expect(verifyAnnounceSignature(malformed1)).toBe(false);
expect(verifyAnnounceSignature(malformed2)).toBe(false);
});
it("wrong-length signature returns false", { timeout: 5000 }, () => {
const a = signAnnounce(SK, baseBody);
const shortSig: GunDBPeerAnnounce = {
...a,
signature: "aa".repeat(32),
};
expect(verifyAnnounceSignature(shortSig)).toBe(false);
const shortPub: GunDBPeerAnnounce = {
...a,
signaturePublicKey: "aa".repeat(16),
};
expect(verifyAnnounceSignature(shortPub)).toBe(false);
});
it("odd-length hex returns false", { timeout: 5000 }, () => {
const a = signAnnounce(SK, baseBody);
const odd: GunDBPeerAnnounce = { ...a, signature: "a".repeat(127) };
expect(verifyAnnounceSignature(odd)).toBe(false);
});
it("fast-check: any valid body round-trips", { timeout: 10_000 }, () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 32 }),
fc.array(fc.string({ minLength: 1, maxLength: 16 }), {
maxLength: 5,
}),
fc.array(fc.string({ minLength: 1, maxLength: 16 }), {
maxLength: 5,
}),
fc.integer({ min: 0, max: 2 ** 50 }),
(peerId, addresses, capabilities, lastSeen) => {
const a = signAnnounce(SK, {
kind: "peer-announce",
peerId,
addresses,
metadata: {},
capabilities,
lastSeen,
});
return verifyAnnounceSignature(a);
}
),
{ numRuns: 50 }
);
});
});
describe("_hex internal codec", () => {
it("round-trips bytes ↔ hex", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0, 1, 15, 16, 254, 255]);
const hex = _hex.toHex(bytes);
expect(hex).toBe("00010f10feff");
expect(_hex.fromHex(hex)).toEqual(bytes);
});
it("fromHex rejects odd length", { timeout: 5000 }, () => {
expect(() => _hex.fromHex("abc")).toThrow();
});
it("fromHex rejects non-hex characters", { timeout: 5000 }, () => {
expect(() => _hex.fromHex("zz")).toThrow();
});
});

View File

@@ -0,0 +1,171 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import type {
GunDBPeerAnnounce,
GunDBPeerAnnounceBody,
} from "../types/types.js";
/**
* Canonical UTF-8 byte serialization of a `GunDBPeerAnnounceBody`.
*
* Two implementations producing different byte sequences would
* silently allow signature forgery, so this routine deliberately
* fixes:
*
* 1. Field order: `kind → peerId → lastSeen → addresses → capabilities → metadata`.
* 2. Addresses + capabilities serialize as `["a","b"]` with no
* sorting (caller-controlled order is part of the announcement).
* 3. `metadata` is serialised with object keys sorted lexicographically
* so different runtime insertion orders produce the same canonical
* bytes. Values are JSON-serialised recursively with the same
* key-sort rule so nested objects are also canonical.
*
* The output is a UTF-8 encoded line of the form:
*
* ```
* peer-announce|<peerId>|<lastSeen>|<JSON addresses>|<JSON capabilities>|<JSON metadata>
* ```
*
* which is deliberately not a valid JSON document — this makes the
* signed input visually distinct from the wire payload it is derived
* from, and removes any temptation for callers to "just JSON.stringify"
* the announce body and end up with a non-deterministic byte string.
*/
export function canonicalSerializeAnnounceBody(
body: GunDBPeerAnnounceBody
): Uint8Array {
const addresses = JSON.stringify(body.addresses);
const capabilities = JSON.stringify(body.capabilities);
const metadata = canonicalJSON(body.metadata);
const line = `${body.kind}|${body.peerId}|${body.lastSeen}|${addresses}|${capabilities}|${metadata}`;
return new TextEncoder().encode(line);
}
/**
* Deterministic JSON serializer: object keys sorted lexicographically
* at every level. Arrays preserve order. Primitives serialise via
* `JSON.stringify`. Functions, symbols, and `undefined` produce
* `JSON.stringify`'s usual `undefined` result.
*/
function canonicalJSON(value: unknown): string {
if (value === null) return "null";
if (typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map((v) => canonicalJSON(v)).join(",")}]`;
}
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
const parts: string[] = [];
for (const k of keys) {
parts.push(`${JSON.stringify(k)}:${canonicalJSON(obj[k])}`);
}
return `{${parts.join(",")}}`;
}
/**
* Hex-encode a `Uint8Array`. Inlined to keep this module dep-free
* beyond `@suaveplan/crypto-curve`.
*/
function toHex(bytes: Uint8Array): string {
let out = "";
for (const b of bytes) {
out += b.toString(16).padStart(2, "0");
}
return out;
}
/**
* Decode a hex string into a `Uint8Array`. Throws on non-hex / odd
* length so callers can catch malformed inputs.
*/
function fromHex(hex: string): Uint8Array {
if (hex.length % 2 !== 0) {
throw new Error("hex string has odd length");
}
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
const byte = Number.parseInt(hex.substr(i * 2, 2), 16);
if (Number.isNaN(byte)) {
throw new Error("hex string contains non-hex characters");
}
out[i] = byte;
}
return out;
}
/**
* Sign a `GunDBPeerAnnounceBody` and return a complete
* `GunDBPeerAnnounce` with `signaturePublicKey` and `signature`
* fields populated.
*/
export function signAnnounce(
privateKey: Uint8Array,
body: GunDBPeerAnnounceBody
): GunDBPeerAnnounce {
if (privateKey.length !== 32) {
throw new Error("ed25519 private key must be 32 bytes");
}
const canonical = canonicalSerializeAnnounceBody(body);
const publicKey = ed25519.getPublicKey(privateKey);
const signature = ed25519.sign(canonical, privateKey);
return {
kind: "peer-announce",
peerId: body.peerId,
addresses: body.addresses,
metadata: body.metadata,
capabilities: body.capabilities,
lastSeen: body.lastSeen,
signaturePublicKey: toHex(publicKey),
signature: toHex(signature),
};
}
/**
* Verify the Ed25519 signature on a `GunDBPeerAnnounce`. Returns
* `true` if and only if (a) `signaturePublicKey` decodes to a
* 32-byte key, (b) `signature` decodes to a 64-byte signature, and
* (c) `ed25519.verify` accepts the pair over the canonical body.
*
* Returns `false` for every other failure mode — malformed hex,
* wrong lengths, verifier rejection. The orchestrator distinguishes
* "no signature fields" (which is `UNSIGNED_ANNOUNCE`) from "verify
* returned false" (which is `BAD_SIGNATURE`) at the call site;
* this function only reports the cryptographic verdict.
*/
export function verifyAnnounceSignature(announce: GunDBPeerAnnounce): boolean {
let pubBytes: Uint8Array;
let sigBytes: Uint8Array;
try {
pubBytes = fromHex(announce.signaturePublicKey);
sigBytes = fromHex(announce.signature);
} catch {
return false;
}
if (pubBytes.length !== 32 || sigBytes.length !== 64) {
return false;
}
const body: GunDBPeerAnnounceBody = {
kind: announce.kind,
peerId: announce.peerId,
addresses: announce.addresses,
metadata: announce.metadata,
capabilities: announce.capabilities,
lastSeen: announce.lastSeen,
};
const canonical = canonicalSerializeAnnounceBody(body);
return ed25519.verify(sigBytes, canonical, pubBytes);
}
/**
* Convenience: hex-encode a 32-byte Ed25519 public key derived from
* a 32-byte secret. Useful for callers that want to compute their
* own `peerId` from `signingKey` before constructing the strategy.
*/
export function publicKeyHex(privateKey: Uint8Array): string {
if (privateKey.length !== 32) {
throw new Error("ed25519 private key must be 32 bytes");
}
return toHex(ed25519.getPublicKey(privateKey));
}
/** Re-exported low-level codecs for tests + advanced callers. */
export const _hex = { toHex, fromHex };

View File

@@ -0,0 +1,61 @@
# envelope-signing
## Purpose
Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every inbound one. The transport adapter MUST go through these helpers — bypassing them would let a malicious relay or peer inject arbitrary messages into a node's inbox. The signing scheme is byte-identical in spirit to the peer-announce signing in `signing.ts`: a deterministic canonical serialization fed to Ed25519, with hex-encoded wire fields.
The canonical serializer sorts object keys lexicographically at every depth so any two impls produce the same bytes. Different bytes would silently allow signature forgery, so the canonicalization is the single most security-critical routine in this module.
## Features
- `signEnvelope(privateKey, senderPeerId, recipientPeerId, envelope) → SignedEnvelopeWire`
- `verifyEnvelopeSignature(wire) → boolean`
- `canonicalSerializeEnvelope(envelope) → Uint8Array`
- Hex-encoded public key + signature on the wire (64 / 128 chars)
- Returns `false` on every malformed input — never throws across the verify boundary
## Usage
### Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { createEnvelope } from "@suaveplan/network";
import {
signEnvelope,
verifyEnvelopeSignature,
} from "@suaveplan/network-discovery-gundb";
const sk = ed25519.randomPrivateKey();
const envelope = createEnvelope({
type: "ping",
senderId: "alice",
targetId: "bob",
payload: { hello: "world" },
});
const wire = signEnvelope(sk, "alice", "bob", envelope);
verifyEnvelopeSignature(wire); // true
```
### Advanced Example
A single-bit flip on the signature MUST cause `verifyEnvelopeSignature` to return `false`. The transport layer uses this to silently drop tampered envelopes and increment `gundb.signature_rejected_total{reason: "bad-signature"}`.
```ts
const wire = signEnvelope(sk, "alice", "bob", envelope);
const flippedLast = wire.signature.slice(0, -1) + "0";
const tampered = { ...wire, signature: flippedLast };
verifyEnvelopeSignature(tampered); // false — drop + count
```
## API Reference
- `canonicalSerializeEnvelope(envelope: MessageEnvelope): Uint8Array` — deterministic UTF-8 canonical form (sorted-keys JSON). Same envelope → same bytes regardless of insertion order.
- `signEnvelope(privateKey, senderPeerId, recipientPeerId, envelope) → SignedEnvelopeWire` — throws on non-32-byte keys.
- `verifyEnvelopeSignature(wire) → boolean` — returns `false` for malformed hex, wrong key/signature lengths, or verify rejection; never throws.
## Implementation Notes
The wire format is deliberately self-contained: `senderPeerId`, `recipientPeerId`, the envelope itself, the announcing public key, and the signature. The receiving transport cross-checks `senderPeerId` against `signaturePublicKey` so a peer cannot impersonate another by reusing a captured-but-still-valid envelope under a different identity. Recipient peerId is on the wire so a passive relay can route without inspecting the envelope.
Hex codecs are inlined (same as `signing.ts`) to keep the file dep-free beyond `@suaveplan/crypto-curve` and `@suaveplan/network`.

View File

@@ -0,0 +1,157 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { createEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
canonicalSerializeEnvelope,
signEnvelope,
verifyEnvelopeSignature,
} from "./envelope-signing.js";
describe("envelope-signing", () => {
it("round-trips: sign → verify succeeds", { timeout: 5000 }, () => {
const sk = ed25519.randomPrivateKey();
const env = createEnvelope({
type: "ping",
senderId: "alice",
targetId: "bob",
payload: { x: 1 },
});
const wire = signEnvelope(sk, "alice", "bob", env);
expect(verifyEnvelopeSignature(wire)).toBe(true);
});
it("verify rejects bit-flipped signatures", { timeout: 5000 }, () => {
const sk = ed25519.randomPrivateKey();
const env = createEnvelope({
type: "ping",
senderId: "alice",
targetId: "bob",
payload: { x: 1 },
});
const wire = signEnvelope(sk, "alice", "bob", env);
const flippedLast = wire.signature.slice(-1) === "0" ? "1" : "0";
const tampered = {
...wire,
signature: wire.signature.slice(0, -1) + flippedLast,
};
expect(verifyEnvelopeSignature(tampered)).toBe(false);
});
it("verify rejects malformed hex", { timeout: 5000 }, () => {
const sk = ed25519.randomPrivateKey();
const env = createEnvelope({
type: "ping",
senderId: "alice",
targetId: "bob",
payload: {},
});
const wire = signEnvelope(sk, "alice", "bob", env);
expect(verifyEnvelopeSignature({ ...wire, signature: "zz" })).toBe(
false
);
expect(
verifyEnvelopeSignature({
...wire,
signaturePublicKey: "z".repeat(64),
})
).toBe(false);
});
it("verify rejects wrong-length public key / signature", {
timeout: 5000,
}, () => {
const sk = ed25519.randomPrivateKey();
const env = createEnvelope({
type: "ping",
senderId: "alice",
targetId: "bob",
payload: {},
});
const wire = signEnvelope(sk, "alice", "bob", env);
expect(verifyEnvelopeSignature({ ...wire, signature: "ab" })).toBe(
false
);
expect(
verifyEnvelopeSignature({ ...wire, signaturePublicKey: "ab" })
).toBe(false);
});
it("signEnvelope rejects non-32-byte private keys", {
timeout: 5000,
}, () => {
const env = createEnvelope({
type: "ping",
senderId: "alice",
targetId: "bob",
payload: {},
});
let threw = false;
try {
signEnvelope(new Uint8Array(16), "alice", "bob", env);
} catch {
threw = true;
}
expect(threw).toBe(true);
});
it("canonicalSerializeEnvelope is insertion-order independent", {
timeout: 5000,
}, () => {
const env = createEnvelope({
type: "ping",
senderId: "alice",
targetId: "bob",
payload: { a: 1, b: 2 },
});
// Manually rebuild an "equivalent" envelope with shuffled key order.
const reordered = {
visitedNodes: env.visitedNodes,
ttl: env.ttl,
headers: env.headers,
timestamp: env.timestamp,
payload: { b: 2, a: 1 },
targetId: env.targetId,
senderId: env.senderId,
type: env.type,
id: env.id,
};
const a = canonicalSerializeEnvelope(env);
const b = canonicalSerializeEnvelope(
reordered as unknown as typeof env
);
expect(a).toEqual(b);
});
it("verify rejects odd-length hex (caught fromHex throw)", {
timeout: 5000,
}, () => {
const sk = ed25519.randomPrivateKey();
const env = createEnvelope({
type: "ping",
senderId: "alice",
targetId: "bob",
payload: {},
});
const wire = signEnvelope(sk, "alice", "bob", env);
// odd-length signature triggers fromHex internal throw.
expect(
verifyEnvelopeSignature({
...wire,
signature: `${wire.signature}a`,
})
).toBe(false);
});
it("canonicalSerializeEnvelope handles null + nested values", {
timeout: 5000,
}, () => {
const env = createEnvelope({
type: "ping",
senderId: "alice",
targetId: "bob",
payload: { nested: { c: null, d: [1, 2] } },
});
const a = canonicalSerializeEnvelope(env);
expect(a.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,118 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import type { MessageEnvelope } from "@suaveplan/network";
/**
* Canonical UTF-8 byte serialization of a `MessageEnvelope` for signing.
*
* Mirrors the security-grade canonicalization used for peer-announces in
* `signing.ts`: object keys at every depth are sorted lexicographically so
* the byte string is independent of runtime insertion order, arrays
* preserve order, primitives serialize via `JSON.stringify`. Using a
* canonical form is mandatory — two implementations producing different
* byte sequences would silently allow signature forgery.
*/
export function canonicalSerializeEnvelope(
envelope: MessageEnvelope
): Uint8Array {
return new TextEncoder().encode(canonicalJSON(envelope));
}
function canonicalJSON(value: unknown): string {
if (value === null) return "null";
if (typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map((v) => canonicalJSON(v)).join(",")}]`;
}
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
const parts: string[] = [];
for (const k of keys) {
parts.push(`${JSON.stringify(k)}:${canonicalJSON(obj[k])}`);
}
return `{${parts.join(",")}}`;
}
function toHex(bytes: Uint8Array): string {
let out = "";
for (const b of bytes) {
out += b.toString(16).padStart(2, "0");
}
return out;
}
function fromHex(hex: string): Uint8Array {
if (hex.length % 2 !== 0) {
throw new Error("hex string has odd length");
}
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
const byte = Number.parseInt(hex.substr(i * 2, 2), 16);
if (Number.isNaN(byte)) {
throw new Error("hex string contains non-hex characters");
}
out[i] = byte;
}
return out;
}
/**
* The on-wire shape transported over GunDB. The receiving transport
* silently drops anything that fails Zod validation or whose signature
* fails to verify — exactly mirroring the peer-announce flow.
*/
export interface SignedEnvelopeWire {
readonly kind: "envelope";
readonly senderPeerId: string;
readonly recipientPeerId: string;
readonly envelope: MessageEnvelope;
readonly signaturePublicKey: string;
readonly signature: string;
}
/**
* Sign an outgoing envelope with the local 32-byte Ed25519 secret. The
* resulting `SignedEnvelopeWire` carries every field needed for the
* receiver to verify authenticity without any out-of-band context.
*/
export function signEnvelope(
privateKey: Uint8Array,
senderPeerId: string,
recipientPeerId: string,
envelope: MessageEnvelope
): SignedEnvelopeWire {
if (privateKey.length !== 32) {
throw new Error("ed25519 private key must be 32 bytes");
}
const canonical = canonicalSerializeEnvelope(envelope);
const publicKey = ed25519.getPublicKey(privateKey);
const signature = ed25519.sign(canonical, privateKey);
return {
kind: "envelope",
senderPeerId,
recipientPeerId,
envelope,
signaturePublicKey: toHex(publicKey),
signature: toHex(signature),
};
}
/**
* Verify the Ed25519 signature over the canonical envelope bytes. Returns
* `true` if and only if the public key + signature decode to valid byte
* lengths AND `ed25519.verify` accepts them.
*/
export function verifyEnvelopeSignature(wire: SignedEnvelopeWire): boolean {
let pubBytes: Uint8Array;
let sigBytes: Uint8Array;
try {
pubBytes = fromHex(wire.signaturePublicKey);
sigBytes = fromHex(wire.signature);
} catch {
return false;
}
if (pubBytes.length !== 32 || sigBytes.length !== 64) {
return false;
}
const canonical = canonicalSerializeEnvelope(wire.envelope);
return ed25519.verify(sigBytes, canonical, pubBytes);
}

View File

@@ -0,0 +1,120 @@
# GunDBTransport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GunDBTransport` implements the `Transport` contract from `@suaveplan/network` over a GunDB relay set, reusing the very same Ed25519 signing key + canonical signing path that the discovery layer uses for peer-announces. The adapter is a thin facade — NOT a bypass — around the existing security primitives in this package: every outbound envelope is signed before it leaves the local process, and every inbound envelope is Zod-validated AND signature-verified before any caller-supplied `onMessage` handler is invoked. Drops are silent and counted; an attacker cannot crash a node by gossiping garbage.
This package was the natural home for a GunDB transport because the discovery package already wires the relay list, the Gun factory, and the signing key. Folding the transport into the same package keeps a single security surface and a single trust anchor; splitting would have meant duplicating signing/verifying code and a risk of drift.
## Security-preserving guarantee
**The transport adapter MUST NOT weaken the security layer.** Specifically:
- Every `send(peerId, envelope)` goes through `signEnvelope` — there is NO unsigned-message path.
- Every inbound payload goes through (1) `SignedEnvelopeWireSchema` Zod parse, (2) `verifyEnvelopeSignature` Ed25519 verify, (3) `senderPeerId == signaturePublicKey` cross-check, (4) `recipientPeerId == localPeerId` cross-check — BEFORE any caller handler is invoked.
- Drops are silent + counted. Reasons land on `gundb.invalid_messages_total{reason}` (malformed / wrong-kind) and `gundb.signature_rejected_total{reason}` (unsigned / bad-signature) — exactly the same counters the discovery layer uses for peer-announces.
If you find yourself wanting to add a "skip signing" or "skip verification" code path, STOP and ask for review — that is by construction a regression in this package's threat model.
## Features
- Full `Transport` interface (`connect` / `disconnect` / `send` / `onMessage` / `onPeerEvent` / `getConnectedPeers` / `close`)
- Ed25519 sign + verify on every envelope, mandatory and not toggleable
- Zod `SignedEnvelopeWireSchema` on every inbound payload — malformed shapes silently dropped + counted
- Constant-time peer-id compare via `@suaveplan/crypto-utils`
- AbortSignal-aware `send`
- Idempotent `connect` and `close`
- Lazy Gun-instance construction (factory called at most once per transport lifecycle)
- Inbox-key dedup so re-broadcasts from GunDB do not re-invoke handlers
- OTel surface: `gundb_transport.{connect,disconnect,send}` spans, `gundb_transport.messages_total{direction,outcome}` counter, `gundb_transport.active_peers` gauge
## Usage
### Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { createEnvelope } from "@suaveplan/network";
import {
GunDBTransport,
publicKeyHex,
} from "@suaveplan/network-discovery-gundb";
const sk = ed25519.randomPrivateKey();
const localPeerId = publicKeyHex(sk);
const transport = new GunDBTransport({
peers: ["https://relay.example.com/gun"],
gunFactory: (cfg) => myGunFactory(cfg),
localPeerId,
signingKey: sk,
});
transport.onMessage((peerId, envelope) => {
console.log("from", peerId, envelope);
});
await transport.connect("peer-2", "ignored-for-gundb");
await transport.send(
"peer-2",
createEnvelope({
type: "ping",
senderId: localPeerId,
targetId: "peer-2",
payload: {},
})
);
```
### Advanced Example
Pair with `GunDBDiscovery` so a single Gun + signing-key powers both peer discovery and message transport. Each plugin gets its own room namespace so the wire paths do not collide.
```ts
import {
GunDBDiscovery,
GunDBTransport,
} from "@suaveplan/network-discovery-gundb";
const discovery = new GunDBDiscovery({
peers,
gunFactory,
localPeerId,
signingKey: sk,
room: "suaveplan-discovery",
});
const transport = new GunDBTransport({
peers,
gunFactory,
localPeerId,
signingKey: sk,
room: "suaveplan-transport",
});
await discovery.start();
await transport.connect(remotePeerId, "ignored");
```
## API Reference
- `new GunDBTransport(config: GunDBTransportConfig)` — throws `NetDiscGunDBError(INVALID_OPTIONS)` on missing/empty `peers`, missing `gunFactory`, or non-32-byte `signingKey`.
- `transport.id: "gundb"` — canonical transport id used by `NetworkNode` to route messages.
- `connect(peerId, address): Promise<void>` — records the peer locally + emits `connected`. Throws `TRANSPORT_CLOSED` after `close()`.
- `disconnect(peerId): Promise<void>` — removes the peer + emits `disconnected`. No-op if unknown.
- `send(peerId, envelope, signal?): Promise<void>` — signs the envelope and writes it to the remote inbox. Throws `PEER_NOT_CONNECTED` / `TRANSPORT_CLOSED` / `SEND_FAILED` on failure paths.
- `onMessage(handler): () => void` — subscribe to verified inbound envelopes only.
- `onPeerEvent(handler): () => void` — subscribe to local connect/disconnect events.
- `getConnectedPeers(): readonly PeerId[]` — snapshot of currently connected peers.
- `close(): Promise<void>` — releases the inbox subscription and clears every peer + handler.
## Implementation Notes
- Wire format is `{kind: "envelope", senderPeerId, recipientPeerId, envelope, signaturePublicKey, signature}` — fully self-contained so no out-of-band context is required to verify integrity.
- `senderPeerId` is required to equal `signaturePublicKey` hex; this enforces a "peerId-from-key" contract that prevents key-substitution attacks where a captured-but-valid envelope is replayed under a different claimed identity.
- The Gun instance is opened lazily on first `connect` / `send` / `close` so callers can construct a transport without committing to a relay connection.
- `inboxKeys` deduplicates by message-id so the GunDB re-broadcast / replay-on-subscribe semantics never re-deliver the same envelope to a handler.
- Handler errors are swallowed so one bad subscriber cannot starve another — same policy as the discovery event bus.
- All security counters (`gundb.invalid_messages_total`, `gundb.signature_rejected_total`) increment from the transport path too, so dashboards do not need separate alerts for "discovery" vs "transport" abuse.

View File

@@ -0,0 +1,842 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { createEnvelope, type MessageEnvelope } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeGun, makeFakeGunFactory } from "../../tests/setup/fake-gun.js";
import { NET_DISC_GUNDB_CODES, NetDiscGunDBError } from "../errors/errors.js";
import { publicKeyHex } from "../signing/signing.js";
import { signEnvelope } from "./envelope-signing.js";
import { GunDBTransport } from "./transport.js";
const RELAYS = ["https://relay.example.com/gun"];
function makeKeyAndId(): { sk: Uint8Array; id: string } {
const sk = ed25519.randomPrivateKey();
return { sk, id: publicKeyHex(sk) };
}
function envelope(senderId: string, targetId: string): MessageEnvelope {
return createEnvelope({
type: "ping",
senderId,
targetId,
payload: { ok: true },
});
}
describe("GunDBTransport — construction", () => {
it("rejects empty relay list", { timeout: 5000 }, () => {
const { sk, id } = makeKeyAndId();
const { factory } = makeFakeGunFactory();
let thrown: NetDiscGunDBError | null = null;
try {
new GunDBTransport({
peers: [],
gunFactory: factory,
localPeerId: id,
signingKey: sk,
});
} catch (err) {
thrown = err as NetDiscGunDBError;
}
expect(thrown).toBeInstanceOf(NetDiscGunDBError);
expect(thrown?.code).toBe(NET_DISC_GUNDB_CODES.INVALID_OPTIONS);
});
it("rejects missing factory", { timeout: 5000 }, () => {
const { sk, id } = makeKeyAndId();
let thrown: NetDiscGunDBError | null = null;
try {
new GunDBTransport({
peers: RELAYS,
gunFactory: undefined as unknown as never,
localPeerId: id,
signingKey: sk,
});
} catch (err) {
thrown = err as NetDiscGunDBError;
}
expect(thrown?.code).toBe(NET_DISC_GUNDB_CODES.INVALID_OPTIONS);
});
it("rejects non-32-byte signing key", { timeout: 5000 }, () => {
const { id } = makeKeyAndId();
const { factory } = makeFakeGunFactory();
let thrown: NetDiscGunDBError | null = null;
try {
new GunDBTransport({
peers: RELAYS,
gunFactory: factory,
localPeerId: id,
signingKey: new Uint8Array(16),
});
} catch (err) {
thrown = err as NetDiscGunDBError;
}
expect(thrown?.code).toBe(NET_DISC_GUNDB_CODES.INVALID_OPTIONS);
});
it("exposes the canonical id", { timeout: 5000 }, () => {
const { sk, id } = makeKeyAndId();
const { factory } = makeFakeGunFactory();
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: factory,
localPeerId: id,
signingKey: sk,
});
expect(t.id).toBe("gundb");
});
});
describe("GunDBTransport — connect / disconnect", () => {
it("emits peer-connected on first connect; idempotent", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const { factory } = makeFakeGunFactory();
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: factory,
localPeerId: id,
signingKey: sk,
});
const events: Array<[string, string]> = [];
t.onPeerEvent((p, e) => events.push([p, e]));
await t.connect("alice", "addr");
await t.connect("alice", "addr");
expect(events).toEqual([["alice", "connected"]]);
expect(t.getConnectedPeers()).toEqual(["alice"]);
});
it("emits disconnected and removes peer on disconnect", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const { factory } = makeFakeGunFactory();
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: factory,
localPeerId: id,
signingKey: sk,
});
const events: Array<[string, string]> = [];
t.onPeerEvent((p, e) => events.push([p, e]));
await t.connect("alice", "addr");
await t.disconnect("alice");
expect(events).toEqual([
["alice", "connected"],
["alice", "disconnected"],
]);
expect(t.getConnectedPeers()).toEqual([]);
});
it("disconnect on unknown peer is a no-op", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const { factory } = makeFakeGunFactory();
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: factory,
localPeerId: id,
signingKey: sk,
});
await t.disconnect("ghost");
expect(t.getConnectedPeers()).toEqual([]);
});
it("connect after close rejects with TRANSPORT_CLOSED", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const { factory } = makeFakeGunFactory();
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: factory,
localPeerId: id,
signingKey: sk,
});
await t.close();
let thrown: NetDiscGunDBError | null = null;
try {
await t.connect("alice", "addr");
} catch (err) {
thrown = err as NetDiscGunDBError;
}
expect(thrown?.code).toBe(NET_DISC_GUNDB_CODES.TRANSPORT_CLOSED);
});
});
describe("GunDBTransport — send", () => {
it("delivers a signed envelope between two transports", {
timeout: 5000,
}, async () => {
const alice = makeKeyAndId();
const bob = makeKeyAndId();
// Both transports share a single Gun graph via a shared factory.
const { factory } = makeFakeGunFactory();
// Re-use the same FakeGun store across both transports by
// building a shared instance.
const shared = new FakeGun({ peers: RELAYS });
const sharedFactory = () => shared;
void factory; // satisfy unused-var
const aliceT = new GunDBTransport({
peers: RELAYS,
gunFactory: sharedFactory,
localPeerId: alice.id,
signingKey: alice.sk,
});
const bobT = new GunDBTransport({
peers: RELAYS,
gunFactory: sharedFactory,
localPeerId: bob.id,
signingKey: bob.sk,
});
const received: Array<{ peerId: string; envelope: MessageEnvelope }> =
[];
bobT.onMessage((peerId, env) =>
received.push({ peerId, envelope: env })
);
await aliceT.connect(bob.id, "addr");
await bobT.connect(alice.id, "addr");
const env = envelope(alice.id, bob.id);
await aliceT.send(bob.id, env);
expect(received.length).toBe(1);
expect(received[0]!.peerId).toBe(alice.id);
expect(received[0]!.envelope.type).toBe("ping");
});
it("send rejects PEER_NOT_CONNECTED for unknown peer", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const { factory } = makeFakeGunFactory();
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: factory,
localPeerId: id,
signingKey: sk,
});
let thrown: NetDiscGunDBError | null = null;
try {
await t.send("ghost", envelope(id, "ghost"));
} catch (err) {
thrown = err as NetDiscGunDBError;
}
expect(thrown?.code).toBe(NET_DISC_GUNDB_CODES.PEER_NOT_CONNECTED);
});
it("send rejects TRANSPORT_CLOSED after close", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const { factory } = makeFakeGunFactory();
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: factory,
localPeerId: id,
signingKey: sk,
});
await t.close();
let thrown: NetDiscGunDBError | null = null;
try {
await t.send("alice", envelope(id, "alice"));
} catch (err) {
thrown = err as NetDiscGunDBError;
}
expect(thrown?.code).toBe(NET_DISC_GUNDB_CODES.TRANSPORT_CLOSED);
});
it("send respects a pre-aborted signal", { timeout: 5000 }, async () => {
const { sk, id } = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: id,
signingKey: sk,
});
await t.connect("alice", "addr");
const ac = new AbortController();
ac.abort();
let err: Error | null = null;
try {
await t.send("alice", envelope(id, "alice"), ac.signal);
} catch (e) {
err = e as Error;
}
expect(err).not.toBeNull();
});
it("send wraps factory failure as SEND_FAILED / RELAY_FAILED", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () => {
throw new Error("relay down");
},
localPeerId: id,
signingKey: sk,
});
await t.connect("alice", "addr").catch(() => {
/* connect opens the gun lazily; an immediate factory failure
* surfaces during connect via ensureGun. */
});
// Trying again after ensureGun has thrown — send still fails.
let thrown: NetDiscGunDBError | null = null;
try {
await t.send("alice", envelope(id, "alice"));
} catch (err) {
thrown = err as NetDiscGunDBError;
}
expect(thrown).toBeInstanceOf(NetDiscGunDBError);
});
});
describe("GunDBTransport — security: unsigned messages", () => {
it("inbound unsigned payload is silently dropped, never delivered", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: id,
signingKey: sk,
});
const received: unknown[] = [];
t.onMessage((_p, env) => received.push(env));
await t.connect("alice", "addr");
// Publish an envelope-shaped payload with empty signature fields.
shared
.get(`transport:suaveplan-transport:${id}:inbox`)
.get("msg-1")
.put({
kind: "envelope",
senderPeerId: "attacker",
recipientPeerId: id,
envelope: envelope("attacker", id),
signaturePublicKey: "",
signature: "",
});
expect(received).toEqual([]);
});
});
describe("GunDBTransport — security: tampered signatures", () => {
it("single-bit-flipped signature is rejected, never delivered", {
timeout: 5000,
}, async () => {
const alice = makeKeyAndId();
const bob = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const bobT = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: bob.id,
signingKey: bob.sk,
});
const received: unknown[] = [];
bobT.onMessage((_p, env) => received.push(env));
await bobT.connect(alice.id, "addr");
const env = envelope(alice.id, bob.id);
const wire = signEnvelope(alice.sk, alice.id, bob.id, env);
const lastChar = wire.signature.slice(-1);
const flipped =
wire.signature.slice(0, -1) + (lastChar === "0" ? "1" : "0");
const tampered = { ...wire, signature: flipped };
shared
.get(`transport:suaveplan-transport:${bob.id}:inbox`)
.get("msg-x")
.put(tampered as unknown as Record<string, unknown>);
expect(received).toEqual([]);
});
});
describe("GunDBTransport — security: peer-id / key mismatch", () => {
it("rejects an envelope whose senderPeerId != signaturePublicKey", {
timeout: 5000,
}, async () => {
const alice = makeKeyAndId();
const bob = makeKeyAndId();
const eve = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const bobT = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: bob.id,
signingKey: bob.sk,
});
const received: unknown[] = [];
bobT.onMessage((_p, env) => received.push(env));
await bobT.connect(alice.id, "addr");
// Eve signs an envelope but claims to be alice on the wire.
const env = envelope(alice.id, bob.id);
const eveSigned = signEnvelope(eve.sk, alice.id, bob.id, env);
shared
.get(`transport:suaveplan-transport:${bob.id}:inbox`)
.get("msg-spoof")
.put(eveSigned as unknown as Record<string, unknown>);
expect(received).toEqual([]);
});
it("rejects an envelope addressed to a different recipient", {
timeout: 5000,
}, async () => {
const alice = makeKeyAndId();
const bob = makeKeyAndId();
const carol = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const bobT = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: bob.id,
signingKey: bob.sk,
});
const received: unknown[] = [];
bobT.onMessage((_p, env) => received.push(env));
await bobT.connect(alice.id, "addr");
// alice addresses carol but the relay leaks it into bob's inbox.
const env = envelope(alice.id, carol.id);
const wire = signEnvelope(alice.sk, alice.id, carol.id, env);
shared
.get(`transport:suaveplan-transport:${bob.id}:inbox`)
.get("msg-wrong")
.put(wire as unknown as Record<string, unknown>);
expect(received).toEqual([]);
});
it("rejects malformed wire payloads (no kind / wrong kind)", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: id,
signingKey: sk,
});
const received: unknown[] = [];
t.onMessage((_p, env) => received.push(env));
await t.connect("alice", "addr");
shared
.get(`transport:suaveplan-transport:${id}:inbox`)
.get("garbage-1")
.put({ kind: "not-envelope" });
shared
.get(`transport:suaveplan-transport:${id}:inbox`)
.get("garbage-2")
.put("not-an-object");
shared
.get(`transport:suaveplan-transport:${id}:inbox`)
.get("garbage-3")
.put(null);
expect(received).toEqual([]);
});
});
describe("GunDBTransport — additional coverage", () => {
it("rejects peerId of different length (constant-time mismatch path)", {
timeout: 5000,
}, async () => {
// The local peerId is short; the wire's recipientPeerId is long
// — this drives peerIdEquals through its length-mismatch branch.
const alice = makeKeyAndId();
const localPeerId = "short-id";
const shared = new FakeGun({ peers: RELAYS });
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId,
signingKey: alice.sk, // any 32-byte key fine
});
const received: unknown[] = [];
t.onMessage((_p, env) => received.push(env));
await t.connect("ghost", "addr");
const env = envelope("anything", "ridiculously-long-recipient-id");
const wire = signEnvelope(
alice.sk,
alice.id,
"ridiculously-long-recipient-id",
env
);
shared
.get(`transport:suaveplan-transport:${localPeerId}:inbox`)
.get("msg-len")
.put(wire as unknown as Record<string, unknown>);
expect(received).toEqual([]);
});
it("onMessage unsubscribe + onPeerEvent unsubscribe both work", {
timeout: 5000,
}, async () => {
const alice = makeKeyAndId();
const bob = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const aliceT = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: alice.id,
signingKey: alice.sk,
});
const bobT = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: bob.id,
signingKey: bob.sk,
});
const received: unknown[] = [];
const peerEvents: unknown[] = [];
const offMsg = bobT.onMessage((_p, env) => received.push(env));
const offPeer = bobT.onPeerEvent((p, e) => peerEvents.push([p, e]));
offMsg();
offPeer();
await aliceT.connect(bob.id, "addr");
await bobT.connect(alice.id, "addr");
await aliceT.send(bob.id, envelope(alice.id, bob.id));
expect(received).toEqual([]);
expect(peerEvents).toEqual([]);
});
it("generateMessageId fallback when envelope id is empty", {
timeout: 5000,
}, async () => {
const alice = makeKeyAndId();
const bob = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const aliceT = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: alice.id,
signingKey: alice.sk,
});
const bobT = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: bob.id,
signingKey: bob.sk,
});
const received: unknown[] = [];
bobT.onMessage((_p, env) => received.push(env));
await aliceT.connect(bob.id, "addr");
await bobT.connect(alice.id, "addr");
// Bypass createEnvelope to inject an empty id past the schema's
// boundary — we cast at the type system but the runtime is what
// matters for the fallback path.
const env = {
id: "",
type: "ping",
senderId: alice.id,
targetId: bob.id,
payload: {},
timestamp: 1,
headers: {},
ttl: 64,
visitedNodes: [],
} as unknown as MessageEnvelope;
await aliceT.send(bob.id, env);
expect(received.length).toBe(1);
});
it("send wraps put-failure as SEND_FAILED", { timeout: 5000 }, async () => {
const alice = makeKeyAndId();
const bob = makeKeyAndId();
// Build a graph that throws on .put
const shared = new FakeGun({ peers: RELAYS });
const throwingPut: ReturnType<typeof shared.get> = {
get: () => throwingPut,
put: () => {
throw new Error("relay rejected");
},
map: () => throwingPut,
on: () => throwingPut,
off: () => throwingPut,
};
const wrapped = {
get: () => throwingPut,
put: () => wrapped,
map: () => wrapped,
on: () => wrapped,
off: () => wrapped,
};
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () =>
wrapped as unknown as ReturnType<typeof shared.get>,
localPeerId: alice.id,
signingKey: alice.sk,
});
await t.connect(bob.id, "addr");
let thrown: NetDiscGunDBError | null = null;
try {
await t.send(bob.id, envelope(alice.id, bob.id));
} catch (err) {
thrown = err as NetDiscGunDBError;
}
expect(thrown?.code).toBe(NET_DISC_GUNDB_CODES.SEND_FAILED);
});
it("ensureGun handles concurrent connect callers", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
let factoryCalls = 0;
const shared = new FakeGun({ peers: RELAYS });
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: async () => {
factoryCalls += 1;
// Defer so two concurrent callers both await the
// in-flight starting promise.
await Promise.resolve();
return shared;
},
localPeerId: id,
signingKey: sk,
});
await Promise.all([
t.connect("alice", "addr"),
t.connect("bob", "addr"),
]);
expect(factoryCalls).toBe(1);
});
it("toAbortError handles non-Error abort reasons (custom string)", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: id,
signingKey: sk,
});
await t.connect("alice", "addr");
const ac = new AbortController();
ac.abort("string-reason");
let err: Error | null = null;
try {
await t.send("alice", envelope(id, "alice"), ac.signal);
} catch (e) {
err = e as Error;
}
expect(err).not.toBeNull();
expect(err?.name).toBe("AbortError");
});
it("factory throwing a non-Error non-string wraps cleanly", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () => {
const reason: unknown = 42;
throw reason;
},
localPeerId: id,
signingKey: sk,
});
let thrown: NetDiscGunDBError | null = null;
try {
await t.connect("alice", "addr");
} catch (err) {
thrown = err as NetDiscGunDBError;
}
expect(thrown?.code).toBe(NET_DISC_GUNDB_CODES.RELAY_FAILED);
});
it("aborts send during ensureGun await (post-ensureGun guard)", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const ac = new AbortController();
let resolveGun: (g: typeof shared) => void = () => undefined;
const gunPromise = new Promise<typeof shared>((resolve) => {
resolveGun = resolve;
});
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () => gunPromise,
localPeerId: id,
signingKey: sk,
});
// Pre-register the peer locally so send() passes the
// peers.has(peerId) gate. We do this by calling connect first
// BUT the in-flight ensureGun is the same gunPromise. We resolve
// it inline so connect returns.
const connectPromise = t.connect("alice", "addr");
resolveGun(shared);
await connectPromise;
// Now send: ensureGun returns cached gun, but we abort BEFORE the
// first signal check is reached. Wait — the pre-ensureGun signal
// check at the top of send fires first. So we need ensureGun to
// run again (cached) but the abort to happen AFTER the pre-check.
// Trick: stash a microtask abort right after the cached await.
// Practically: when ensureGun is cached, line 217 awaits a
// resolved promise, then line 218 fires. We need signal aborted
// BETWEEN line 206 and line 218. The pre-check (line 206) sees
// signal.aborted = false, then ensureGun's microtask resolves
// the promise — and during that microtask we abort. Schedule the
// abort as a microtask that fires after the pre-check.
queueMicrotask(() => ac.abort());
let err: Error | null = null;
try {
await t.send("alice", envelope(id, "alice"), ac.signal);
} catch (e) {
err = e as Error;
}
expect(err?.name).toBe("AbortError");
});
it("handleIncomingPayload ignores replay (same key twice)", {
timeout: 5000,
}, async () => {
const alice = makeKeyAndId();
const bob = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const bobT = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: bob.id,
signingKey: bob.sk,
});
const received: unknown[] = [];
bobT.onMessage((_p, env) => received.push(env));
await bobT.connect(alice.id, "addr");
const env = envelope(alice.id, bob.id);
const wire = signEnvelope(alice.sk, alice.id, bob.id, env);
const inboxPath = `transport:suaveplan-transport:${bob.id}:inbox`;
// First put delivers; second put under the same key is a replay.
shared
.get(inboxPath)
.get("dup-key")
.put(wire as unknown as Record<string, unknown>);
shared
.get(inboxPath)
.get("dup-key")
.put(wire as unknown as Record<string, unknown>);
expect(received.length).toBe(1);
});
it("factory throwing a non-Error wraps cleanly as RELAY_FAILED", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () => {
const reason: unknown = "literal";
throw reason;
},
localPeerId: id,
signingKey: sk,
});
let thrown: NetDiscGunDBError | null = null;
try {
await t.connect("alice", "addr");
} catch (err) {
thrown = err as NetDiscGunDBError;
}
expect(thrown?.code).toBe(NET_DISC_GUNDB_CODES.RELAY_FAILED);
});
it("malformed payload — kind=envelope with valid-shaped sig but extra fields", {
timeout: 5000,
}, async () => {
// This triggers the final 'malformed' return in
// classifyParseFailure: kind is right, signature/pub fields are
// non-empty strings, but the schema still rejects (missing
// recipientPeerId / wrong hex length / etc.).
const { sk, id } = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: id,
signingKey: sk,
});
const received: unknown[] = [];
t.onMessage((_p, env) => received.push(env));
await t.connect("alice", "addr");
shared
.get(`transport:suaveplan-transport:${id}:inbox`)
.get("malformed-1")
.put({
kind: "envelope",
// missing senderPeerId / recipientPeerId / envelope
signature: "deadbeef",
signaturePublicKey: "cafebabe",
});
expect(received).toEqual([]);
});
});
describe("GunDBTransport — close", () => {
it("close is idempotent and clears handlers", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const t = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: id,
signingKey: sk,
});
await t.connect("alice", "addr");
await t.close();
await t.close();
expect(t.isClosed()).toBe(true);
expect(t.getConnectedPeers()).toEqual([]);
});
it("delivers handler-throws without leaking the exception", {
timeout: 5000,
}, async () => {
const alice = makeKeyAndId();
const bob = makeKeyAndId();
const shared = new FakeGun({ peers: RELAYS });
const bobT = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: bob.id,
signingKey: bob.sk,
});
bobT.onMessage(() => {
throw new Error("handler boom");
});
const aliceT = new GunDBTransport({
peers: RELAYS,
gunFactory: () => shared,
localPeerId: alice.id,
signingKey: alice.sk,
});
await aliceT.connect(bob.id, "addr");
await bobT.connect(alice.id, "addr");
// Should not throw.
await aliceT.send(bob.id, envelope(alice.id, bob.id));
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,460 @@
import { constantTimeEqual } from "@suaveplan/crypto-utils";
import type { MessageEnvelope, PeerId, Transport } from "@suaveplan/network";
import z from "zod";
import { NET_DISC_GUNDB_CODES, NetDiscGunDBError } from "../errors/errors.js";
import {
recordInvalidMessage,
recordSignatureRejection,
recordTransportActivePeers,
recordTransportMessage,
withTransportConnectSpan,
withTransportDisconnectSpan,
withTransportSendSpan,
} from "../otel/otel.js";
import type {
GunFactory,
GunFactoryConfig,
GunNodeLike,
} from "../types/types.js";
import {
type SignedEnvelopeWire,
signEnvelope,
verifyEnvelopeSignature,
} from "./envelope-signing.js";
const encoder = new TextEncoder();
/**
* Constant-time peer-id equality over UTF-8 bytes. Used so a probe peer
* cannot leak the local peer-id via timing.
*/
function peerIdEquals(a: string, b: string): boolean {
const ba = encoder.encode(a);
const bb = encoder.encode(b);
if (ba.length !== bb.length) {
const max = Math.max(ba.length, bb.length);
const padA = new Uint8Array(max);
const padB = new Uint8Array(max);
padA.set(ba);
padB.set(bb);
constantTimeEqual(padA, padB);
return false;
}
return constantTimeEqual(ba, bb);
}
/**
* Wire-payload Zod schema for transport messages. Mirrors the strict
* `peer-announce` shape — every field is non-optional, signature lengths
* are pinned to their Ed25519 sizes.
*/
const HexStringSchema = z
.string()
.min(1)
.regex(/^[0-9a-fA-F]+$/);
const SignedEnvelopeWireSchema = z
.object({
kind: z.literal("envelope"),
senderPeerId: z.string().min(1),
recipientPeerId: z.string().min(1),
envelope: z.unknown(),
signaturePublicKey: HexStringSchema.length(64),
signature: HexStringSchema.length(128),
})
.strict();
const GunDBTransportOptionsSchema = z
.object({
peers: z.array(z.string().url()).min(1),
room: z.string().min(1).default("suaveplan-transport"),
localStorage: z.boolean().default(false),
gunFactory: z.unknown(),
localPeerId: z.string().min(1),
signingKey: z.instanceof(Uint8Array).refine((b) => b.length === 32, {
message: "signingKey must be exactly 32 bytes",
}),
})
.strict();
/**
* Strict configuration for `GunDBTransport`. Construction throws
* `INVALID_OPTIONS` on a missing/empty relay list, a non-callable
* factory, or a non-32-byte signing key.
*/
export interface GunDBTransportConfig {
/** Relay (peer) URLs. REQUIRED, non-empty. No default fallback. */
readonly peers: readonly string[];
/** Transport room/namespace. Default `"suaveplan-transport"`. */
readonly room?: string | undefined;
/** Optional localStorage opt-in. Default `false`. */
readonly localStorage?: boolean | undefined;
/** Required factory wiring the underlying Gun instance. */
readonly gunFactory: GunFactory;
/** Local peer id — used as our inbox key. */
readonly localPeerId: string;
/** Local 32-byte Ed25519 secret key. Used to sign every outbound envelope. */
readonly signingKey: Uint8Array;
}
type ResolvedGunDBTransportConfig = Required<
Omit<GunDBTransportConfig, "localStorage">
> & {
readonly localStorage: boolean;
};
interface PeerEntry {
readonly peerId: PeerId;
readonly address: string;
}
type MessageHandler = (peerId: PeerId, envelope: MessageEnvelope) => void;
type PeerEventHandler = (
peerId: PeerId,
event: "connected" | "disconnected"
) => void;
/**
* `Transport` adapter that gossips `MessageEnvelope`s over a GunDB
* relay set. Every outbound envelope is Ed25519-signed by the same
* `signingKey` used for discovery announces; every inbound envelope is
* Zod-validated AND signature-verified BEFORE the caller's `onMessage`
* handler is ever invoked. Drops are silent — an attacker cannot crash
* a node by gossiping garbage. Every silent drop increments
* `gundb.invalid_messages_total{reason}` (and
* `gundb.signature_rejected_total{reason}` for cryptographic failures),
* exactly as the discovery layer does for peer-announces.
*
* GunDB is connectionless — `connect(peerId, address)` records the peer
* in a local map and fires a `connected` event; the underlying gossip
* relay is opened once at first send / first subscribe.
*/
export class GunDBTransport implements Transport {
public readonly id = "gundb";
private readonly config: ResolvedGunDBTransportConfig;
private readonly peers = new Map<PeerId, PeerEntry>();
private readonly messageHandlers = new Set<MessageHandler>();
private readonly peerEventHandlers = new Set<PeerEventHandler>();
private gun: GunNodeLike | null = null;
private inboxNode: GunNodeLike | null = null;
private inboxKeys = new Set<string>();
private closed = false;
private starting: Promise<void> | null = null;
public constructor(rawConfig: GunDBTransportConfig) {
const parsed = GunDBTransportOptionsSchema.safeParse(rawConfig);
if (!parsed.success) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.INVALID_OPTIONS,
"errors.gundb.invalid_options",
{ cause: parsed.error }
);
}
if (typeof rawConfig.gunFactory !== "function") {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.INVALID_OPTIONS,
"errors.gundb.factory_required"
);
}
this.config = {
peers: parsed.data.peers,
room: parsed.data.room,
localStorage: parsed.data.localStorage,
gunFactory: rawConfig.gunFactory,
localPeerId: parsed.data.localPeerId,
signingKey: parsed.data.signingKey,
};
}
public async connect(peerId: PeerId, address: string): Promise<void> {
if (this.closed) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.TRANSPORT_CLOSED,
"errors.gundb.transport_closed"
);
}
await withTransportConnectSpan(async (span) => {
span.setAttribute("peerId", peerId);
await this.ensureGun();
if (this.peers.has(peerId)) return;
this.peers.set(peerId, { peerId, address });
recordTransportActivePeers(1);
this.emitPeerEvent(peerId, "connected");
});
}
public async disconnect(peerId: PeerId): Promise<void> {
await withTransportDisconnectSpan(async (span) => {
span.setAttribute("peerId", peerId);
if (!this.peers.delete(peerId)) return;
recordTransportActivePeers(-1);
this.emitPeerEvent(peerId, "disconnected");
});
}
public async send(
peerId: PeerId,
envelope: MessageEnvelope,
signal?: AbortSignal
): Promise<void> {
if (this.closed) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.TRANSPORT_CLOSED,
"errors.gundb.transport_closed"
);
}
if (signal?.aborted) throw toAbortError(signal);
if (!this.peers.has(peerId)) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.PEER_NOT_CONNECTED,
"errors.gundb.peer_not_connected",
{ context: { peerId } }
);
}
await withTransportSendSpan(async (span) => {
span.setAttribute("peerId", peerId);
const gun = await this.ensureGun();
if (signal?.aborted) throw toAbortError(signal);
// Sign every outbound envelope through the canonical signing
// path — this is the load-bearing security guarantee. There
// is NO bypass.
const wire = signEnvelope(
this.config.signingKey,
this.config.localPeerId,
peerId,
envelope
);
const messageId = generateMessageId(envelope);
try {
gun.get(this.inboxPath(peerId))
.get(messageId)
.put(wire as unknown as Record<string, unknown>);
} catch (cause) {
recordTransportMessage("outbound", "error");
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.SEND_FAILED,
"errors.gundb.send_failed",
{ context: { peerId }, cause: cause as Error }
);
}
recordTransportMessage("outbound", "ok");
});
}
public onMessage(handler: MessageHandler): () => void {
this.messageHandlers.add(handler);
return () => {
this.messageHandlers.delete(handler);
};
}
public onPeerEvent(handler: PeerEventHandler): () => void {
this.peerEventHandlers.add(handler);
return () => {
this.peerEventHandlers.delete(handler);
};
}
public getConnectedPeers(): readonly PeerId[] {
return Array.from(this.peers.keys());
}
public async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
if (this.inboxNode !== null) {
try {
this.inboxNode.off();
} catch {
/* swallow */
}
this.inboxNode = null;
}
for (const peerId of this.peers.keys()) {
recordTransportActivePeers(-1);
this.emitPeerEvent(peerId, "disconnected");
}
this.peers.clear();
this.messageHandlers.clear();
this.peerEventHandlers.clear();
this.inboxKeys.clear();
this.gun = null;
}
/** Whether {@link close} has been called. */
public isClosed(): boolean {
return this.closed;
}
/**
* Open the underlying Gun instance lazily on first use. The factory
* is called at most once per transport lifecycle; concurrent callers
* await the same in-flight `Promise`.
*/
private async ensureGun(): Promise<GunNodeLike> {
if (this.gun !== null) return this.gun;
if (this.starting !== null) {
await this.starting;
return this.gun as unknown as GunNodeLike;
}
this.starting = (async () => {
const factoryConfig: GunFactoryConfig = {
peers: this.config.peers,
localStorage: this.config.localStorage,
};
let instance: GunNodeLike;
try {
instance = await this.config.gunFactory(factoryConfig);
} catch (err) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.RELAY_FAILED,
"errors.gundb.relay_failed",
{ cause: asError(err) }
);
}
this.gun = instance;
this.inboxNode = instance.get(
this.inboxPath(this.config.localPeerId)
);
this.inboxNode.map().on((data: unknown, key: string) => {
this.handleIncomingPayload(data, key);
});
})();
try {
await this.starting;
} finally {
this.starting = null;
}
return this.gun as unknown as GunNodeLike;
}
/**
* Validate + verify an inbound payload. Every drop path mirrors the
* discovery layer: Zod-shape failure → `invalid_messages_total`,
* unsigned → `signature_rejected_total{reason: "unsigned"}`,
* tampered → `signature_rejected_total{reason: "bad-signature"}`.
* Never throws.
*/
private handleIncomingPayload(data: unknown, key: string): void {
// Replay-protection: ignore messages we have already processed.
// The fake-gun harness emits children once at subscription time
// + again on every put; production Gun also re-broadcasts. The
// key is unique per message under our send() impl. After close()
// the inbox subscription is detached, so this handler will not
// fire — no need to gate on `this.closed`.
if (this.inboxKeys.has(key)) return;
const parsed = SignedEnvelopeWireSchema.safeParse(data);
if (!parsed.success) {
const reason = classifyParseFailure(data);
recordInvalidMessage(reason);
if (reason === "unsigned") {
recordSignatureRejection("unsigned");
}
recordTransportMessage("inbound", "error");
return;
}
const wire = parsed.data as SignedEnvelopeWire;
if (!verifyEnvelopeSignature(wire)) {
recordInvalidMessage("bad-signature");
recordSignatureRejection("bad-signature");
recordTransportMessage("inbound", "error");
return;
}
// Spoof-prevention: senderPeerId on the wire MUST equal the
// hex-encoded public key that signed it. The transport contract
// is "peerId == hex(pubkey)" — any mismatch is a spoof attempt
// and is silently dropped with `invalid_messages_total{reason}`
// bumped (just like a malformed payload).
if (!peerIdEquals(wire.senderPeerId, wire.signaturePublicKey)) {
recordInvalidMessage("wrong-kind");
recordTransportMessage("inbound", "error");
return;
}
// Recipient must be us — passive relays MAY route to multiple
// inboxes but a well-formed envelope is addressed.
if (!peerIdEquals(wire.recipientPeerId, this.config.localPeerId)) {
recordInvalidMessage("wrong-kind");
recordTransportMessage("inbound", "error");
return;
}
this.inboxKeys.add(key);
recordTransportMessage("inbound", "ok");
for (const handler of this.messageHandlers) {
try {
handler(wire.senderPeerId, wire.envelope);
} catch {
// Handler errors are swallowed — one bad subscriber
// cannot starve another. Same policy as discovery bus.
}
}
}
private inboxPath(peerId: string): string {
return `transport:${this.config.room}:${peerId}:inbox`;
}
private emitPeerEvent(
peerId: PeerId,
event: "connected" | "disconnected"
): void {
for (const handler of this.peerEventHandlers) {
try {
handler(peerId, event);
} catch {
/* swallow */
}
}
}
}
let messageIdCounter = 0;
function generateMessageId(envelope: MessageEnvelope): string {
// Prefer the envelope's own id so the message-key under GunDB is
// stable + unique; fall back to a monotonically-increasing counter
// when the envelope id is empty (defensive — schema requires min 1).
if (typeof envelope.id === "string" && envelope.id.length > 0) {
return envelope.id;
}
messageIdCounter += 1;
return `m-${Date.now()}-${messageIdCounter}`;
}
function classifyParseFailure(
data: unknown
): "unsigned" | "wrong-kind" | "malformed" {
if (data === null || typeof data !== "object") return "malformed";
const obj = data as Record<string, unknown>;
if ("kind" in obj && obj["kind"] !== "envelope") return "wrong-kind";
const sig = obj["signature"];
const pub = obj["signaturePublicKey"];
if (
typeof sig !== "string" ||
sig.length === 0 ||
typeof pub !== "string" ||
pub.length === 0
) {
return "unsigned";
}
return "malformed";
}
function asError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(typeof value === "string" ? value : String(value));
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

View File

@@ -0,0 +1,132 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type surface for `@suaveplan/network-discovery-gundb`. Defines the
configuration contract (`GunDBDiscoveryConfig` /
`ResolvedGunDBDiscoveryConfig`), the wire-payload shapes that traverse
the gossip network (`GunDBMessage`, `GunDBPeerAnnounce`,
`GunDBPeerAnnounceBody`), the in-memory peer-record shape
(`GunDBPeerMetadata`), the structural Gun-instance contract
(`GunNodeLike` + `GunFactory`), and the typed event map
(`GunDBEventMap`) emitted on the optional `EventBusLike`.
These types are referenced by the orchestrator, the signing helpers, and
every test file. Zod schemas mirroring this surface live in
`./types.schemas.ts` and are the runtime contract enforced at every
boundary — both for caller-supplied configuration and for incoming wire
messages.
## Features
- `GunDBDiscoveryConfig` makes the **relay list mandatory and non-empty**
via Zod's `peers: z.array(z.string().url()).min(1)`. There is no public
default. Upstream's `https://gun-manhattan.herokuapp.com/gun` fallback
is gone.
- `gunFactory` is mandatory — no implicit `Gun(config)` construction.
- `signingKey` is a 32-byte `Uint8Array` (Ed25519 secret); every
outbound announce is signed over the canonical body.
- `GunDBPeerAnnounce` carries both `signaturePublicKey` and `signature`;
the body that is signed is the strict subset `GunDBPeerAnnounceBody`
(announce minus the two signature fields).
- Wire-payload validation is enforced via `GunDBMessageSchema`
(`z.discriminatedUnion("kind", ...)`) so future message kinds can
extend the union without rewriting the dispatcher.
- `GunNodeLike` is intentionally tiny — `get` / `put` / `map` / `on` /
`off` — so the deterministic in-memory `FakeGun` test fake covers the
full surface without `vi.mock`.
- `InvalidMessageReason` enumerates exactly the four reasons an inbound
payload may be silently dropped: `malformed`, `wrong-kind`,
`unsigned`, `bad-signature`. The orchestrator increments
`gundb.invalid_messages_total{reason}` for each.
## Usage: Basic Example
```ts
import type { GunDBDiscoveryConfig } from "@suaveplan/network-discovery-gundb";
const config: GunDBDiscoveryConfig = {
peers: ["wss://my-private-relay.example.com/gun"],
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[] });
},
localPeerId: "peer-local",
signingKey: new Uint8Array(32), // ⚠ real callers must use a random key
};
```
## Usage: Advanced Example
```ts
import type {
GunDBDiscoveryConfig,
GunDBEventMap,
GunDBPeerMetadata,
} from "@suaveplan/network-discovery-gundb";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<GunDBEventMap>();
bus.on("gundb:peer-discovered", ({ peer }: { peer: GunDBPeerMetadata }) => {
console.log("joined:", peer.id, peer.addresses);
});
bus.on("gundb:relay-error", ({ reason }) => {
console.warn("relay:", reason);
});
const config: GunDBDiscoveryConfig = {
peers: ["wss://relay-a.example.com/gun", "wss://relay-b.example.com/gun"],
room: "my-app/v1",
maxPeers: 256,
peerTtlMs: 120_000,
announceIntervalMs: 15_000,
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[], localStorage: cfg.localStorage });
},
localPeerId: "peer-local",
signingKey: crypto.getRandomValues(new Uint8Array(32)),
bus,
};
```
## API Reference
- `GunDBDiscoveryConfig` — input config. `peers` non-empty, `gunFactory`
callable, `localPeerId` non-empty, `signingKey` 32-byte Ed25519
secret. Optional: `room`, `localStorage`, `announceIntervalMs`,
`maxPeers`, `peerTtlMs`, `bus`, `registry`.
- `ResolvedGunDBDiscoveryConfig` — same shape with every optional field
defaulted. Exposed as `discovery.config`.
- `GunDBPeerMetadata` — in-memory peer record. Mutable `lastSeen`.
- `GunDBPeerAnnounce` — full wire shape including `signature` +
`signaturePublicKey`.
- `GunDBPeerAnnounceBody` — the subset that is canonically serialized
and signed.
- `GunDBMessage` — discriminated-union envelope of every wire kind.
- `GunNodeLike` — structural Gun-instance contract used by the
orchestrator and the `FakeGun` test fake.
- `GunFactory` / `GunFactoryConfig` — sync-or-async factory contract.
- `GunDBEventMap` — typed event map for the optional `EventBusLike`.
- `InvalidMessageReason` — `"malformed" | "wrong-kind" | "unsigned" |
"bad-signature"`. Drives the
`gundb.invalid_messages_total{reason}` counter.
## Implementation Notes
- Type-only — no runtime code. The runtime Zod boundary lives in
`./types.schemas.ts`.
- `signaturePublicKey` is hex-encoded (64 hex chars = 32 bytes) rather
than base64 to match the Ed25519 conventions used elsewhere in the
project and to keep the canonical signing input string-comparable.
- The `kind: "peer-announce"` discriminator is mandatory on every wire
payload. Future message kinds (heartbeat, leave) extend the
discriminated union; the orchestrator rejects anything with an
unknown `kind` as `wrong-kind`.
- `GunNodeLike` deliberately omits Gun's chained `set`/`time`/`map(cb)`
shapes — the strategy never uses them, so the test fake doesn't need
to implement them, and consumers can't accidentally lean on
behaviours we don't test.

View File

@@ -0,0 +1,78 @@
import z from "zod";
/**
* Zod boundary schemas for every public option struct and every wire
* payload in `@suaveplan/network-discovery-gundb`. Callable contracts
* (factory, `EventBusLike`, `ServiceRegistry`) are type-only and accepted
* here as `z.unknown()` — the constructor enforces presence via a runtime
* check.
*
* The relay list is non-optional and non-empty. There is no implicit
* public default — construction must fail loudly if the list is missing
* or empty.
*
* The `signingKey` is parsed as an `instanceof Uint8Array` constraint of
* exactly 32 bytes (Ed25519 private-key length).
*/
const HexStringSchema = z
.string()
.min(1)
.regex(/^[0-9a-fA-F]+$/);
export const GunDBPeerAnnounceBodySchema = z
.object({
kind: z.literal("peer-announce"),
peerId: z.string().min(1),
addresses: z.array(z.string()).readonly(),
metadata: z.record(z.string(), z.unknown()),
capabilities: z.array(z.string()).readonly(),
lastSeen: z.number().int().nonnegative(),
})
.strict();
export const GunDBPeerAnnounceSchema = z
.object({
kind: z.literal("peer-announce"),
peerId: z.string().min(1),
addresses: z.array(z.string()).readonly(),
metadata: z.record(z.string(), z.unknown()),
capabilities: z.array(z.string()).readonly(),
lastSeen: z.number().int().nonnegative(),
signaturePublicKey: HexStringSchema.length(64),
signature: HexStringSchema.length(128),
})
.strict();
export const GunDBMessageSchema = z.discriminatedUnion("kind", [
GunDBPeerAnnounceSchema,
]);
export const GunDBPeerMetadataSchema = z
.object({
id: z.string().min(1),
addresses: z.array(z.string()).readonly(),
metadata: z.record(z.string(), z.unknown()),
capabilities: z.array(z.string()).readonly(),
connectedAt: z.number().int().nonnegative(),
lastSeen: z.number().int().nonnegative(),
})
.strict();
export const GunDBDiscoveryConfigSchema = z
.object({
peers: z.array(z.string().url()).min(1),
room: z.string().min(1).default("suaveplan-discovery"),
localStorage: z.boolean().default(false),
announceIntervalMs: z.number().int().positive().default(30_000),
maxPeers: z.number().int().positive().default(100),
peerTtlMs: z.number().int().positive().default(60_000),
gunFactory: z.unknown(),
localPeerId: z.string().min(1),
signingKey: z.instanceof(Uint8Array).refine((b) => b.length === 32, {
message: "signingKey must be exactly 32 bytes",
}),
bus: z.unknown().optional(),
registry: z.unknown().optional(),
})
.strict();

View File

@@ -0,0 +1,223 @@
import type { ServiceRegistry } from "@suaveplan/service-discovery";
import type { EventBusLike } from "@suaveplan/types";
/**
* Hex-encoded Ed25519 public key (32 bytes → 64 hex chars). Identifies the
* announcing peer. Verification uses this key to validate the
* accompanying signature over the canonical announce body. This does not
* by itself establish identity trust — it only proves the announce body
* was not tampered with in flight.
*/
export type HexEd25519PublicKey = string;
/**
* Hex-encoded Ed25519 signature (64 bytes → 128 hex chars).
*/
export type HexEd25519Signature = string;
/**
* Peer metadata tracked by `GunDBDiscovery`. The id is the announcing
* peer's identifier (any non-empty string — typically a hex hash or peer
* address); `addresses` is the multiaddr-style list of reachable endpoints
* the peer advertises; `lastSeen` is the unix-ms timestamp of the most
* recent valid announce.
*/
export interface GunDBPeerMetadata {
readonly id: string;
readonly addresses: readonly string[];
readonly metadata: Readonly<Record<string, unknown>>;
readonly capabilities: readonly string[];
/** Unix-ms timestamp of first observation. */
readonly connectedAt: number;
/** Mutable in-place — updated on each fresh valid announce. */
lastSeen: number;
}
/**
* Canonical wire format for a peer-announce gossiped over GunDB. Every
* incoming announce MUST Zod-parse as `GunDBPeerAnnounce` AND carry a
* valid Ed25519 signature over the canonical body or the strategy drops
* it silently and increments the `invalid_messages_total{reason}` /
* `signature_rejected_total{reason}` counters.
*/
export interface GunDBPeerAnnounce {
/** Discriminator. */
readonly kind: "peer-announce";
/** Announcing peer identifier. */
readonly peerId: string;
/** Multiaddr-style endpoint list. */
readonly addresses: readonly string[];
/** Free-form peer-supplied metadata. */
readonly metadata: Readonly<Record<string, unknown>>;
/** Peer-advertised capability strings. */
readonly capabilities: readonly string[];
/** Announcer's unix-ms wall-clock at publish time. */
readonly lastSeen: number;
/** Hex-encoded Ed25519 public key of the announcer. */
readonly signaturePublicKey: HexEd25519PublicKey;
/** Hex-encoded Ed25519 signature over the canonical announce body. */
readonly signature: HexEd25519Signature;
}
/**
* The portion of a peer-announce that is signed. Canonically serialized
* via `canonicalSerializeAnnounceBody` before signing / verifying. The
* `signature` and `signaturePublicKey` fields are deliberately excluded
* from this body.
*/
export interface GunDBPeerAnnounceBody {
readonly kind: "peer-announce";
readonly peerId: string;
readonly addresses: readonly string[];
readonly metadata: Readonly<Record<string, unknown>>;
readonly capabilities: readonly string[];
readonly lastSeen: number;
}
/**
* Discriminated-union envelope of every GunDB wire-message kind. Future
* kinds (heartbeat, leave, etc.) extend this union; today only
* `peer-announce` is defined.
*/
export type GunDBMessage = GunDBPeerAnnounce;
/**
* Structural surface of a `Gun` instance used by this package. Real
* production code passes a real `Gun(config)` chain via the factory;
* tests pass an in-memory deterministic fake conforming to this exact
* shape — no `vi.mock` ever needed.
*
* The contract is intentionally minimal — `get`/`put`/`map`/`on`/`off`
* cover every operation the strategy performs.
*/
export interface GunNodeLike {
get(path: string): GunNodeLike;
put(value: unknown): GunNodeLike;
map(): GunNodeLike;
on(handler: (data: unknown, key: string) => void): GunNodeLike;
off(): GunNodeLike;
}
/**
* Factory wiring `(config) => GunNodeLike`. Production code passes
* `(config) => Gun(config)`; tests pass a deterministic in-memory
* implementation. The factory MAY be async to permit dynamic imports.
*
* The factory receives only the inert `peers` / `localStorage` shape
* the strategy needs — never the announceId / room key, which the
* strategy applies via `.get(...)` after construction.
*/
export type GunFactory = (
config: GunFactoryConfig
) => GunNodeLike | Promise<GunNodeLike>;
/**
* Subset of Gun's constructor config the strategy threads through. Real
* Gun accepts many more fields; we only forward what callers can
* reasonably configure here. Anything else MUST be wired inside the
* factory.
*/
export interface GunFactoryConfig {
readonly peers: readonly string[];
readonly localStorage?: boolean | undefined;
}
/**
* Strict configuration for `GunDBDiscovery`.
*
* **`peers` is REQUIRED and may not be empty.** There is no implicit
* public-relay fallback. Callers MUST supply their own relay list — the
* package will not provide a default and will throw `INVALID_OPTIONS`
* if the list is missing or empty.
*
* The `gunFactory` is REQUIRED — production code wires the real `Gun`
* constructor; tests bind a deterministic fake. There is no
* default-construction of a real `Gun` in the strategy itself.
*
* The `localPeerId` + `signingKey` pair is REQUIRED so the strategy can
* sign its own announces. The signing key is a 32-byte Ed25519 secret
* (any 32 random bytes is valid Ed25519). See
* `@suaveplan/network-discovery-gundb/signing` for helpers to build a
* fresh announce.
*/
export interface GunDBDiscoveryConfig {
/** Relay (peer) URLs. REQUIRED, non-empty. No default fallback. */
readonly peers: readonly string[];
/** Discovery room/namespace name. Default `"suaveplan-discovery"`. */
readonly room?: string | undefined;
/** Optional localStorage opt-in. Default `false`. */
readonly localStorage?: boolean | undefined;
/** Announce interval (ms). Default `30_000`. */
readonly announceIntervalMs?: number | undefined;
/** Hard cap on tracked peers. Default `100`. */
readonly maxPeers?: number | undefined;
/** Peer eviction TTL (ms). Default `60_000`. */
readonly peerTtlMs?: number | undefined;
/** Required factory wiring the underlying Gun instance. */
readonly gunFactory: GunFactory;
/** Local peer id — used as the GunDB record key for this peer. */
readonly localPeerId: string;
/** Local 32-byte Ed25519 secret key. Used to sign every announce. */
readonly signingKey: Uint8Array;
/** Optional event bus for typed emissions. */
readonly bus?: EventBusLike<GunDBEventMap> | undefined;
/** Optional service registry to mirror discovered peers into. */
readonly registry?: ServiceRegistry | undefined;
}
/** Fully-resolved configuration — every optional field defaulted. */
export interface ResolvedGunDBDiscoveryConfig {
readonly peers: readonly string[];
readonly room: string;
readonly localStorage: boolean;
readonly announceIntervalMs: number;
readonly maxPeers: number;
readonly peerTtlMs: number;
readonly gunFactory: GunFactory;
readonly localPeerId: string;
readonly signingKey: Uint8Array;
readonly bus: EventBusLike<GunDBEventMap> | undefined;
readonly registry: ServiceRegistry | undefined;
}
/** Typed events emitted on the optional `EventBusLike`. */
export interface GunDBStartedEvent {
readonly room: string;
readonly relayCount: number;
readonly at: number;
}
export interface GunDBDiscoveredEvent {
readonly peer: GunDBPeerMetadata;
readonly at: number;
}
export interface GunDBLostEvent {
readonly peerId: string;
readonly at: number;
}
export interface GunDBRelayErrorEvent {
readonly relay: string | undefined;
readonly reason: string;
readonly at: number;
}
export interface GunDBStoppedEvent {
readonly at: number;
}
export type GunDBEventMap = {
"gundb:started": GunDBStartedEvent;
"gundb:peer-discovered": GunDBDiscoveredEvent;
"gundb:peer-lost": GunDBLostEvent;
"gundb:relay-error": GunDBRelayErrorEvent;
"gundb:stopped": GunDBStoppedEvent;
};
/** Reasons a wire payload may be silently dropped. */
export type InvalidMessageReason =
| "malformed"
| "wrong-kind"
| "unsigned"
| "bad-signature";

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-discovery-gundb dep-graph", () => {
it("must not have circular dependencies", { timeout: 5000 }, async () => {
const mod = await import("../src/index.js");
expect(mod).toBeDefined();
expect(mod.GunDBDiscovery).toBeDefined();
expect(mod.NET_DISC_GUNDB_CODES).toBeDefined();
expect(mod.NetDiscGunDBError).toBeDefined();
expect(mod.GunDBDiscoveryConfigSchema).toBeDefined();
expect(mod.GunDBMessageSchema).toBeDefined();
expect(mod.GunDBPeerAnnounceSchema).toBeDefined();
expect(mod.GunDBPeerAnnounceBodySchema).toBeDefined();
expect(mod.GunDBPeerMetadataSchema).toBeDefined();
expect(mod.withStartSpan).toBeDefined();
expect(mod.recordPeersDiscovered).toBeDefined();
expect(mod.recordInvalidMessage).toBeDefined();
expect(mod.recordSignatureRejection).toBeDefined();
expect(mod.recordRelayError).toBeDefined();
expect(mod.recordAnnounceDuration).toBeDefined();
expect(mod.signAnnounce).toBeDefined();
expect(mod.verifyAnnounceSignature).toBeDefined();
expect(mod.publicKeyHex).toBeDefined();
expect(mod.canonicalSerializeAnnounceBody).toBeDefined();
});
});

View File

@@ -0,0 +1,124 @@
import type { GunFactoryConfig, GunNodeLike } from "../../src/types/types.js";
/**
* In-memory deterministic implementation of the `GunNodeLike` surface.
* Used across every test in this package so the real `gun` runtime is
* never pulled into the test environment (it expects `window` /
* `localStorage` / WebSocket globals and has side effects) and
* `vi.mock` is never needed.
*
* Behavioural model:
*
* - The "graph" is a `Map<string, unknown>`. `get(path)` returns a
* node-handle scoped to that path; chained `get(child)` extends the
* path with `/<child>`.
* - `put(value)` writes the value at the scoped path AND fires every
* registered `.map().on(handler)` listener whose root prefix
* matches the path. The handler receives `(value, key)` where
* `key` is the leaf segment past the listener's root.
* - `map().on(handler)` subscribes the current scope's children. The
* handler is invoked once for every existing child at subscription
* time, then again on every subsequent `put`.
* - `off()` detaches every subscription created on the current scope.
*
* This is a deliberately tiny subset of real Gun's semantics — just
* enough to drive the orchestrator. Anything more would risk
* coupling the test fake to Gun's actual gossip behaviour and lose
* determinism.
*/
export class FakeGun implements GunNodeLike {
public readonly trackers: readonly string[];
public destroyed = false;
private readonly store: Map<string, unknown>;
private readonly listeners: Map<
string,
Array<(data: unknown, key: string) => void>
>;
private readonly path: string;
public constructor(
config: GunFactoryConfig,
store?: Map<string, unknown>,
listeners?: Map<string, Array<(data: unknown, key: string) => void>>,
path = ""
) {
this.trackers = config.peers;
this.store = store ?? new Map();
this.listeners = listeners ?? new Map();
this.path = path;
}
public get(segment: string): GunNodeLike {
const next = this.path === "" ? segment : `${this.path}/${segment}`;
return new FakeGun(
{ peers: this.trackers },
this.store,
this.listeners,
next
);
}
public put(value: unknown): GunNodeLike {
this.store.set(this.path, value);
// Notify every listener whose root is a prefix of this.path
// AND whose path is exactly one segment shallower (i.e. the
// `.map()` of the parent).
for (const [root, handlers] of this.listeners.entries()) {
const prefix = root === "" ? "" : `${root}/`;
if (!this.path.startsWith(prefix)) continue;
const rest = this.path.slice(prefix.length);
if (rest === "" || rest.includes("/")) continue;
for (const h of [...handlers]) {
h(value, rest);
}
}
return this;
}
public map(): GunNodeLike {
// map() in this fake is a marker — the subsequent .on(handler)
// subscribes to the children of the current path.
return this;
}
public on(handler: (data: unknown, key: string) => void): GunNodeLike {
const list = this.listeners.get(this.path) ?? [];
list.push(handler);
this.listeners.set(this.path, list);
// Replay every existing child at the time of subscription.
const prefix = this.path === "" ? "" : `${this.path}/`;
for (const [k, v] of this.store.entries()) {
if (!k.startsWith(prefix)) continue;
const rest = k.slice(prefix.length);
if (rest === "" || rest.includes("/")) continue;
handler(v, rest);
}
return this;
}
public off(): GunNodeLike {
this.listeners.delete(this.path);
return this;
}
}
/**
* Factory helper producing a `FakeGun` and recording every instance the
* factory created so the test can introspect destroy / put state and
* drive late events through a captured reference.
*/
export function makeFakeGunFactory(): {
readonly instances: FakeGun[];
readonly factory: (config: GunFactoryConfig) => FakeGun;
} {
const instances: FakeGun[] = [];
return {
instances,
factory: (config) => {
const inst = new FakeGun(config);
instances.push(inst);
return inst;
},
};
}

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,45 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-discovery-gundb",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/crypto-curve",
"@suaveplan/crypto-utils",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/network",
"@suaveplan/network-bootstrap",
"@suaveplan/service-discovery",
"@suaveplan/timing",
"@suaveplan/types",
"gun",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});