@robota-sdk/agent-transport-webrtc 3.0.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +694 -0
- package/LICENSE +661 -0
- package/README.md +111 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1650 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1650 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +93 -0
- package/src/__tests__/admission-secure-by-default.test.ts +63 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/device-mesh-relay.test.ts +441 -0
- package/src/__tests__/device-mesh.test.ts +682 -0
- package/src/__tests__/discovering-mesh-relay.test.ts +234 -0
- package/src/__tests__/enrollment-link.test.ts +103 -0
- package/src/__tests__/fake-datachannel.ts +83 -0
- package/src/__tests__/handoff-grant-gate.test.ts +403 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +215 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/mesh-discovery.test.ts +820 -0
- package/src/__tests__/mesh-fixtures.ts +148 -0
- package/src/__tests__/mesh-internet.test.ts +942 -0
- package/src/__tests__/mesh-turn-relay.test.ts +161 -0
- package/src/__tests__/negotiated-certificate.test.ts +76 -0
- package/src/__tests__/nostr-source-condition.test.ts +27 -0
- package/src/__tests__/operator-approval-gate.test.ts +308 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +155 -0
- package/src/__tests__/pairing-e2e.test.ts +237 -0
- package/src/__tests__/pairing-gate-e3.test.ts +194 -0
- package/src/__tests__/pairing-gate-e4.test.ts +110 -0
- package/src/__tests__/pairing-gate.test.ts +296 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +225 -0
- package/src/__tests__/rtc-channel-close.test.ts +52 -0
- package/src/__tests__/rtc-peer-candidates.test.ts +61 -0
- package/src/__tests__/session-attachment-app-view.test.ts +66 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/turn-server-bind.test.ts +59 -0
- package/src/__tests__/turn-server.test.ts +335 -0
- package/src/__tests__/turn-test-client.ts +256 -0
- package/src/__tests__/webrtc-implementation.test.ts +83 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/ws-mesh-relay-client.test.ts +95 -0
- package/src/__tests__/ws-signaling-client.test.ts +108 -0
- package/src/admission-steps.ts +55 -0
- package/src/channel-delivery.ts +44 -0
- package/src/datachannel-loader.ts +86 -0
- package/src/device-mesh-node.ts +1019 -0
- package/src/discovering-mesh-relay.ts +675 -0
- package/src/enrollment-link.ts +465 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +139 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/mainline-dht-store.ts +174 -0
- package/src/mesh-dht.ts +446 -0
- package/src/mesh-discovery.ts +87 -0
- package/src/mesh-item-store.ts +287 -0
- package/src/mesh-lan-listener.ts +193 -0
- package/src/mesh-mdns.ts +406 -0
- package/src/mesh-peer-link.ts +600 -0
- package/src/mesh-public-relays.ts +25 -0
- package/src/mesh-records.ts +357 -0
- package/src/mesh-relay.ts +98 -0
- package/src/mesh-signal.ts +116 -0
- package/src/mesh-turn-relay.ts +238 -0
- package/src/negotiated-certificate.ts +72 -0
- package/src/nostr-mesh-relay.ts +391 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +81 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +133 -0
- package/src/pairing-gate.ts +397 -0
- package/src/rtc-peer.ts +429 -0
- package/src/session-attachment.ts +96 -0
- package/src/signaling.ts +62 -0
- package/src/stun-message.ts +341 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/turn-server.ts +1028 -0
- package/src/webrtc-delivery-lifecycle.ts +54 -0
- package/src/webrtc-transport-options.ts +87 -0
- package/src/webrtc-transport.ts +368 -0
- package/src/ws-mesh-relay-client.ts +117 -0
- package/src/ws-signaling-client.ts +139 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export { WebRtcTransport } from './webrtc-transport.js';
|
|
2
|
+
export type { IWebRtcTransportOptions, IIceServer } from './webrtc-transport-options.js';
|
|
3
|
+
export type {
|
|
4
|
+
IConnectionApproval,
|
|
5
|
+
IConnectionApprovalContext,
|
|
6
|
+
IHostReconnectConfig,
|
|
7
|
+
} from './pairing-gate.js';
|
|
8
|
+
// The judge and the frame predicate stay internal: they are this package's policy plumbing, and a
|
|
9
|
+
// composition root only needs to SUPPLY the port and, on the peer side, know the frame's shape.
|
|
10
|
+
export { localProofFrame } from './local-peer-proof.js';
|
|
11
|
+
export type { ILocalPeerProof, ILocalProofFrame } from './local-peer-proof.js';
|
|
12
|
+
// SEC-011 (issue #1865): the cross-device hand-off grant gate. The verdict is injected — this
|
|
13
|
+
// package implements no cryptographic policy.
|
|
14
|
+
export { handoffGrantFrame, judgeHandoffGrant } from './handoff-grant-gate.js';
|
|
15
|
+
export type { IHandoffGrantFrame, IHandoffGrantProof } from './handoff-grant-gate.js';
|
|
16
|
+
export { createInMemorySignalingPair } from './signaling.js';
|
|
17
|
+
export type { ISignalingClient, ISignalMessage, TSignalKind } from './signaling.js';
|
|
18
|
+
export { WsSignalingClient } from './ws-signaling-client.js';
|
|
19
|
+
export type { IWsSignalingClientOptions, IWebSocketLike } from './ws-signaling-client.js';
|
|
20
|
+
export { loadDataChannel } from './datachannel-loader.js';
|
|
21
|
+
// One peer connection over the WebRTC implementation this package runs on (hosts, tests, tools).
|
|
22
|
+
export { RtcChannel, RtcPeer } from './rtc-peer.js';
|
|
23
|
+
export type {
|
|
24
|
+
IRtcCandidate,
|
|
25
|
+
IRtcPeerDiagnostics,
|
|
26
|
+
IRtcPeerOptions,
|
|
27
|
+
TRtcChannelState,
|
|
28
|
+
TRtcPeerState,
|
|
29
|
+
} from './rtc-peer.js';
|
|
30
|
+
export type { IDataChannelModule, TModuleResolver } from './datachannel-loader.js';
|
|
31
|
+
// The device mesh: CLI↔CLI connections between one user's devices, admitted by the device handshake.
|
|
32
|
+
export { DeviceMeshNode } from './device-mesh-node.js';
|
|
33
|
+
export type {
|
|
34
|
+
IDeviceMeshLink,
|
|
35
|
+
IDeviceMeshNodeOptions,
|
|
36
|
+
IDeviceMeshRefusal,
|
|
37
|
+
IDeviceMeshRelayOptions,
|
|
38
|
+
} from './device-mesh-node.js';
|
|
39
|
+
// The relay of last resort: a TURN server one of the user's devices runs for its paired devices.
|
|
40
|
+
export {
|
|
41
|
+
DEFAULT_RELAY_CREDENTIAL_TTL_MS,
|
|
42
|
+
MAX_RELAY_CREDENTIAL_TTL_MS,
|
|
43
|
+
MeshRelayNeededError,
|
|
44
|
+
MeshTurnRelay,
|
|
45
|
+
meshRelayCredential,
|
|
46
|
+
meshRelayIceServers,
|
|
47
|
+
} from './mesh-turn-relay.js';
|
|
48
|
+
export type {
|
|
49
|
+
TMeshRelayNeed,
|
|
50
|
+
IMeshRelayEndpoint,
|
|
51
|
+
IMeshRelayPeer,
|
|
52
|
+
IMeshTurnRelayOptions,
|
|
53
|
+
} from './mesh-turn-relay.js';
|
|
54
|
+
export { DEFAULT_TURN_QUOTAS, DEFAULT_UNAUTHENTICATED_LIMITS, TurnServer } from './turn-server.js';
|
|
55
|
+
export type {
|
|
56
|
+
ITurnAuthorization,
|
|
57
|
+
ITurnQuotas,
|
|
58
|
+
ITurnServerOptions,
|
|
59
|
+
IUnauthenticatedLimits,
|
|
60
|
+
} from './turn-server.js';
|
|
61
|
+
export { MeshLinkEndedError } from './mesh-peer-link.js';
|
|
62
|
+
export type {
|
|
63
|
+
IMeshChannelBinding,
|
|
64
|
+
TMeshLinkEnd,
|
|
65
|
+
TMeshLinkRole,
|
|
66
|
+
TMeshLinkStage,
|
|
67
|
+
} from './mesh-peer-link.js';
|
|
68
|
+
export { MAX_MESH_MESSAGE_CHARS } from './mesh-signal.js';
|
|
69
|
+
export { createInMemoryMeshRelayHub } from './mesh-relay.js';
|
|
70
|
+
export type { IInMemoryMeshRelayHub, IMeshRelay } from './mesh-relay.js';
|
|
71
|
+
export { WsMeshRelayClient } from './ws-mesh-relay-client.js';
|
|
72
|
+
export type { IWsMeshRelayClientOptions } from './ws-mesh-relay-client.js';
|
|
73
|
+
// Finding a peer device before the relay: the address cache, mDNS, then public records, each
|
|
74
|
+
// yielding candidates only; then public signaling carriers, then the self-hosted relay.
|
|
75
|
+
export { DiscoveringMeshRelay, startLanMeshRelay } from './discovering-mesh-relay.js';
|
|
76
|
+
export type {
|
|
77
|
+
IDiscoveringMeshRelayOptions,
|
|
78
|
+
IMeshAdvertiser,
|
|
79
|
+
IStartLanMeshRelayOptions,
|
|
80
|
+
} from './discovering-mesh-relay.js';
|
|
81
|
+
export { DEFAULT_MAX_PUBLISH_JITTER_MS, MeshDht } from './mesh-dht.js';
|
|
82
|
+
export type { IFetchedLists, IMeshDhtOptions, IPublishedLists } from './mesh-dht.js';
|
|
83
|
+
export { createInMemoryItemNetwork, createPkarrRelayStore } from './mesh-item-store.js';
|
|
84
|
+
export type {
|
|
85
|
+
IInMemoryItemNetwork,
|
|
86
|
+
IPkarrRelayStoreOptions,
|
|
87
|
+
IRendezvousItemStore,
|
|
88
|
+
} from './mesh-item-store.js';
|
|
89
|
+
export { startMainlineDhtStore } from './mainline-dht-store.js';
|
|
90
|
+
export type { IMainlineDht, IMainlineDhtStoreOptions } from './mainline-dht-store.js';
|
|
91
|
+
export {
|
|
92
|
+
NostrMeshRelay,
|
|
93
|
+
createInMemoryNostrHub,
|
|
94
|
+
createNostrRelayPool,
|
|
95
|
+
} from './nostr-mesh-relay.js';
|
|
96
|
+
export type {
|
|
97
|
+
IInMemoryNostrHub,
|
|
98
|
+
INostrEvent,
|
|
99
|
+
INostrFilter,
|
|
100
|
+
INostrMeshRelayOptions,
|
|
101
|
+
INostrRelayPool,
|
|
102
|
+
} from './nostr-mesh-relay.js';
|
|
103
|
+
export {
|
|
104
|
+
MAX_CACHED_CANDIDATES,
|
|
105
|
+
addressCacheSource,
|
|
106
|
+
createInMemoryMeshAddressCache,
|
|
107
|
+
} from './mesh-discovery.js';
|
|
108
|
+
export type {
|
|
109
|
+
IMeshAddressCache,
|
|
110
|
+
IMeshCandidate,
|
|
111
|
+
IMeshCandidateSource,
|
|
112
|
+
IMeshPeerRoute,
|
|
113
|
+
} from './mesh-discovery.js';
|
|
114
|
+
export { startMeshLanListener } from './mesh-lan-listener.js';
|
|
115
|
+
export type { IMeshLanListener, IMeshLanListenerOptions } from './mesh-lan-listener.js';
|
|
116
|
+
export {
|
|
117
|
+
MESH_MDNS_SERVICE,
|
|
118
|
+
MeshMdns,
|
|
119
|
+
createInMemoryMdnsBus,
|
|
120
|
+
localInterfaceAddresses,
|
|
121
|
+
paddedInstanceCount,
|
|
122
|
+
} from './mesh-mdns.js';
|
|
123
|
+
export { DEFAULT_NOSTR_RELAYS, DEFAULT_PKARR_RELAYS } from './mesh-public-relays.js';
|
|
124
|
+
export type {
|
|
125
|
+
IInMemoryMdnsBus,
|
|
126
|
+
IMdnsPacket,
|
|
127
|
+
IMdnsRecord,
|
|
128
|
+
IMdnsTransport,
|
|
129
|
+
IMeshMdnsOptions,
|
|
130
|
+
} from './mesh-mdns.js';
|
|
131
|
+
// Enrolling a new device: a data channel bound to its negotiated connection, reached through the relay
|
|
132
|
+
// topic an enrollment code derives. The enrollment protocol itself is `agent-remote-pairing`'s.
|
|
133
|
+
export { EnrollmentLinkError, dialEnrollment, listenForEnrollment } from './enrollment-link.js';
|
|
134
|
+
export type {
|
|
135
|
+
IEnrollmentChannel,
|
|
136
|
+
IEnrollmentListener,
|
|
137
|
+
IEnrollmentRendezvous,
|
|
138
|
+
TEnrollmentLinkFailure,
|
|
139
|
+
} from './enrollment-link.js';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-010 TC-08: joining the rendezvous proof to the channel the session will run on.
|
|
3
|
+
*
|
|
4
|
+
* ## What each half proves, and why neither is enough alone
|
|
5
|
+
*
|
|
6
|
+
* The pairing handshake binds the CHANNEL: after it, this data channel provably terminates at the
|
|
7
|
+
* peer that knew the secret, and the DTLS fingerprints are covered so it cannot be spliced. It says
|
|
8
|
+
* nothing about where that peer runs.
|
|
9
|
+
*
|
|
10
|
+
* The guarded rendezvous binds the ENVIRONMENT: a peer that reached a 0700 directory owned by this
|
|
11
|
+
* user could only have come from this machine, as this user. It says nothing about which channel
|
|
12
|
+
* that peer later opens.
|
|
13
|
+
*
|
|
14
|
+
* Presenting the rendezvous nonce OVER the already-bound channel is what joins them. The channel is
|
|
15
|
+
* authenticated by the time this runs, so nobody else can inject the frame; the nonce is single-use,
|
|
16
|
+
* so nobody can re-present one they observed. Together they say: the party on this channel is the
|
|
17
|
+
* party the kernel vouched for, on this attempt.
|
|
18
|
+
*
|
|
19
|
+
* ## Why this runs after the handshake and before the session
|
|
20
|
+
*
|
|
21
|
+
* Both edges are load-bearing.
|
|
22
|
+
*
|
|
23
|
+
* BEFORE the handshake completes, the channel is not yet authenticated — a nonce presented there
|
|
24
|
+
* would be a secret handed to an unproven counterpart, and this step would weaken the very thing it
|
|
25
|
+
* exists to strengthen.
|
|
26
|
+
*
|
|
27
|
+
* BEFORE the session is exposed, because "fail closed before content" is SEC-010's first failure
|
|
28
|
+
* rule. If the proof arrived after exposure, a peer that never presents one would have already been
|
|
29
|
+
* talking to the session, and refusing it afterwards is not refusing it.
|
|
30
|
+
*
|
|
31
|
+
* ## This package decides nothing
|
|
32
|
+
*
|
|
33
|
+
* #1810 is explicit that WebRTC must not implement cryptographic policy. Single-use, expiry and
|
|
34
|
+
* revocation belong to the grant ledger in `@robota-sdk/agent-remote-pairing/local`, which is
|
|
35
|
+
* node-only; this module takes an injected `redeem` and reports what it was told. That also keeps
|
|
36
|
+
* `node:fs` out of this package, and keeps the ledger testable without a data channel.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type { IPeerAdmission } from '@robota-sdk/agent-interface-session-mobility';
|
|
40
|
+
|
|
41
|
+
/** The frame a local peer presents to show it reached the guarded rendezvous. */
|
|
42
|
+
export interface ILocalProofFrame {
|
|
43
|
+
readonly t: 'local-proof';
|
|
44
|
+
readonly nonce: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The local-environment proof this gate requires before exposing the session.
|
|
49
|
+
*
|
|
50
|
+
* Absent → the gate behaves exactly as before. Requiring the proof is opt-in because a remote peer
|
|
51
|
+
* over WebRTC has no rendezvous to have reached, and a gate that demanded one unconditionally would
|
|
52
|
+
* refuse every legitimate remote session.
|
|
53
|
+
*/
|
|
54
|
+
export interface ILocalPeerProof {
|
|
55
|
+
/**
|
|
56
|
+
* Redeem a presented nonce. Owned by the ledger — this package asks and does not decide.
|
|
57
|
+
*
|
|
58
|
+
* Returns the admission the consumer receives, so the trust level travels as a value rather than
|
|
59
|
+
* being re-derived from a boolean by whoever reads it next.
|
|
60
|
+
*/
|
|
61
|
+
readonly redeem: (nonce: string) => IPeerAdmission;
|
|
62
|
+
/**
|
|
63
|
+
* Fired on the outcome, admitted or not.
|
|
64
|
+
*
|
|
65
|
+
* On BOTH, deliberately: a consumer told only about successes cannot distinguish "no local peer
|
|
66
|
+
* connected" from "a local peer was refused", and those call for different operator responses.
|
|
67
|
+
*/
|
|
68
|
+
readonly onAdmission?: (admission: IPeerAdmission) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* True when a parsed pre-accept value is a local-proof frame carrying a nonce.
|
|
73
|
+
*
|
|
74
|
+
* Module-private: `judgeLocalProof` is the only thing that should ask. Exporting it would offer a
|
|
75
|
+
* caller a way to check the shape and then act on it without going through the judge, which is how
|
|
76
|
+
* an admission decision ends up made in two places.
|
|
77
|
+
*/
|
|
78
|
+
function isLocalProofFrame(value: unknown): value is ILocalProofFrame {
|
|
79
|
+
return (
|
|
80
|
+
typeof value === 'object' &&
|
|
81
|
+
value !== null &&
|
|
82
|
+
(value as { t?: unknown }).t === 'local-proof' &&
|
|
83
|
+
typeof (value as { nonce?: unknown }).nonce === 'string'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A refusal shaped like every other admission, so no caller has to special-case the failure path. */
|
|
88
|
+
function refuse(reason: string): IPeerAdmission {
|
|
89
|
+
return { admitted: false, trust: 'unproven', reason };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Judge one pre-accept frame against the configured proof.
|
|
94
|
+
*
|
|
95
|
+
* Every path that is not an admitted redemption returns a refusal — including a missing config,
|
|
96
|
+
* which cannot happen through the gate but would be a fail-OPEN if it ever did.
|
|
97
|
+
*/
|
|
98
|
+
export function judgeLocalProof(
|
|
99
|
+
parsed: unknown,
|
|
100
|
+
proof: ILocalPeerProof | undefined,
|
|
101
|
+
): IPeerAdmission {
|
|
102
|
+
if (proof === undefined) return refuse('no local-peer proof is configured for this channel');
|
|
103
|
+
if (!isLocalProofFrame(parsed)) {
|
|
104
|
+
return refuse('expected a local-proof frame carrying the rendezvous nonce');
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return proof.redeem(parsed.nonce);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// allow-fallback: fail-CLOSED, not a fallback to a degraded path. A ledger that throws has not
|
|
110
|
+
// reached a decision, and "not reached" is not "allowed" — the refusal below is strictly more
|
|
111
|
+
// restrictive than the success path, never less. Letting it propagate would be worse: the throw
|
|
112
|
+
// unwinds through the channel's message subscription and leaves the gate parked in its proof
|
|
113
|
+
// state with the channel open, which is a hang — fail-open wearing a crash's clothes.
|
|
114
|
+
return refuse(
|
|
115
|
+
`the rendezvous ledger could not decide: ${
|
|
116
|
+
error instanceof Error ? error.message : 'unknown error'
|
|
117
|
+
}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build the frame a local peer presents on the channel.
|
|
124
|
+
*
|
|
125
|
+
* Lives beside the judge that reads it, so the two cannot drift: a sender that hand-built the object
|
|
126
|
+
* would keep compiling after the frame gains a field, and the failure would surface as a refusal on
|
|
127
|
+
* the far side with no hint that the SENDER is the stale half.
|
|
128
|
+
*
|
|
129
|
+
* Deliberately not a "send" — it returns the frame and leaves transmission to whoever owns the
|
|
130
|
+
* channel. A helper that both built and sent would need a channel to be testable, and would put the
|
|
131
|
+
* frame's shape and the carrier's lifecycle in one place.
|
|
132
|
+
*/
|
|
133
|
+
export function localProofFrame(nonce: string): ILocalProofFrame {
|
|
134
|
+
return { t: 'local-proof', nonce };
|
|
135
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendezvous records on the BitTorrent Mainline DHT, as BEP 44 mutable items, straight from this
|
|
3
|
+
* process over UDP — no relay in between.
|
|
4
|
+
*
|
|
5
|
+
* DHT nodes are strangers: they see the one-time key, the rotating salt and ciphertext, and the
|
|
6
|
+
* publisher's address, never who the record is for. Every item a lookup returns is checked here
|
|
7
|
+
* against the key and salt asked for, whatever the nodes claimed to check.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
BEP44_MAX_VALUE_BYTES,
|
|
13
|
+
concatBytes,
|
|
14
|
+
signMutableItem,
|
|
15
|
+
verifyMutableItem,
|
|
16
|
+
type IMutableItem,
|
|
17
|
+
} from './mesh-records.js';
|
|
18
|
+
|
|
19
|
+
import type { IRendezvousItemStore } from './mesh-item-store.js';
|
|
20
|
+
|
|
21
|
+
/** A BEP 44 item as `bittorrent-dht` hands it over. */
|
|
22
|
+
interface IDhtGetResult {
|
|
23
|
+
readonly v?: Uint8Array;
|
|
24
|
+
readonly k?: Uint8Array;
|
|
25
|
+
readonly sig?: Uint8Array;
|
|
26
|
+
readonly seq?: number;
|
|
27
|
+
readonly salt?: Uint8Array;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The slice of a `bittorrent-dht` instance this store drives. */
|
|
31
|
+
export interface IMainlineDht {
|
|
32
|
+
put(
|
|
33
|
+
item: {
|
|
34
|
+
readonly k: Buffer;
|
|
35
|
+
readonly salt: Buffer;
|
|
36
|
+
readonly seq: number;
|
|
37
|
+
readonly v: Buffer;
|
|
38
|
+
readonly sig: Buffer;
|
|
39
|
+
},
|
|
40
|
+
callback: (error: Error | null, hash: Buffer, responses?: number) => void,
|
|
41
|
+
): void;
|
|
42
|
+
get(
|
|
43
|
+
target: Buffer,
|
|
44
|
+
options: { readonly salt: Buffer; readonly cache: false },
|
|
45
|
+
callback: (error: Error | null, result: IDhtGetResult | null) => void,
|
|
46
|
+
): void;
|
|
47
|
+
on(event: 'error' | 'warning', listener: (error: Error) => void): void;
|
|
48
|
+
destroy(callback?: () => void): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IMainlineDhtStoreOptions {
|
|
52
|
+
/** Test seam: the DHT client; default a `bittorrent-dht` node on the public bootstrap nodes. */
|
|
53
|
+
readonly createDht?: (verify: TDhtVerify) => IMainlineDht | Promise<IMainlineDht>;
|
|
54
|
+
readonly onError?: (error: Error) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type TDhtVerify = (signature: Uint8Array, message: Uint8Array, publicKey: Uint8Array) => boolean;
|
|
58
|
+
|
|
59
|
+
const ED25519_SPKI_PREFIX = Uint8Array.from([
|
|
60
|
+
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** Synchronous Ed25519 verification, as the DHT client asks for it. Never throws. */
|
|
64
|
+
export const verifyEd25519Sync: TDhtVerify = (signature, message, publicKey) => {
|
|
65
|
+
if (publicKey.length !== 32 || signature.length !== 64) return false;
|
|
66
|
+
try {
|
|
67
|
+
const key = createPublicKey({
|
|
68
|
+
key: Buffer.from(concatBytes([ED25519_SPKI_PREFIX, publicKey])),
|
|
69
|
+
format: 'der',
|
|
70
|
+
type: 'spki',
|
|
71
|
+
});
|
|
72
|
+
return verifySignature(null, message, key, signature);
|
|
73
|
+
} catch {
|
|
74
|
+
// allow-fallback: a key or signature that cannot be read verifies nothing
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The DHT client package; it ships no types, so it is loaded by name and narrowed here. */
|
|
80
|
+
const DHT_MODULE: string = 'bittorrent-dht';
|
|
81
|
+
|
|
82
|
+
/** A DHT node on the public bootstrap nodes. */
|
|
83
|
+
async function defaultDht(verify: TDhtVerify): Promise<IMainlineDht> {
|
|
84
|
+
// eslint-disable-next-line no-restricted-syntax -- truly conditional: only a device that turns the DHT on loads it, and the package is ESM-only
|
|
85
|
+
const module = (await import(DHT_MODULE)) as {
|
|
86
|
+
default: new (options: { verify: TDhtVerify }) => IMainlineDht;
|
|
87
|
+
};
|
|
88
|
+
return new module.default({ verify });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toBuffer(bytes: Uint8Array): Buffer {
|
|
92
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function aborted(signal: AbortSignal | undefined): Promise<never> | undefined {
|
|
96
|
+
if (signal === undefined) return undefined;
|
|
97
|
+
return new Promise((_resolve, reject) => {
|
|
98
|
+
if (signal.aborted) reject(new Error('aborted'));
|
|
99
|
+
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A Mainline DHT store. The node joins the DHT when the store is created and stays for the life of
|
|
105
|
+
* the store, since joining takes seconds a lookup should not wait on each time.
|
|
106
|
+
*/
|
|
107
|
+
export async function startMainlineDhtStore(
|
|
108
|
+
options: IMainlineDhtStoreOptions = {},
|
|
109
|
+
): Promise<IRendezvousItemStore> {
|
|
110
|
+
const dht = await (options.createDht ?? defaultDht)(verifyEd25519Sync);
|
|
111
|
+
const report = (error: Error): void => options.onError?.(error);
|
|
112
|
+
dht.on('error', report);
|
|
113
|
+
dht.on('warning', report);
|
|
114
|
+
let closed = false;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
async put(address, value, now, signal) {
|
|
118
|
+
if (closed) throw new Error('DHT store closed');
|
|
119
|
+
if (value.length > BEP44_MAX_VALUE_BYTES) throw new Error('record value exceeds 999 bytes');
|
|
120
|
+
const item = await signMutableItem(address.key, address.salt, Math.floor(now / 1000), value);
|
|
121
|
+
const put = new Promise<void>((resolve, reject) => {
|
|
122
|
+
dht.put(
|
|
123
|
+
{
|
|
124
|
+
k: toBuffer(item.k),
|
|
125
|
+
salt: toBuffer(address.salt),
|
|
126
|
+
seq: item.seq,
|
|
127
|
+
v: toBuffer(item.v),
|
|
128
|
+
sig: toBuffer(item.sig),
|
|
129
|
+
},
|
|
130
|
+
(error, _hash, responses) => {
|
|
131
|
+
if (error !== null) reject(error);
|
|
132
|
+
else if (responses === 0) reject(new Error('no DHT node took the record'));
|
|
133
|
+
else resolve();
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
const abort = aborted(signal);
|
|
138
|
+
await (abort === undefined ? put : Promise.race([put, abort]));
|
|
139
|
+
},
|
|
140
|
+
async get(publicKey, salt, signal) {
|
|
141
|
+
if (closed || signal.aborted) return undefined;
|
|
142
|
+
const target = createHash('sha1')
|
|
143
|
+
.update(concatBytes([publicKey, salt]))
|
|
144
|
+
.digest();
|
|
145
|
+
const lookup = new Promise<IDhtGetResult | null>((resolve) => {
|
|
146
|
+
dht.get(target, { salt: toBuffer(salt), cache: false }, (error, result) =>
|
|
147
|
+
resolve(error === null ? result : null),
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
const result = await Promise.race([lookup, aborted(signal)!]);
|
|
152
|
+
if (result?.v === undefined || result.k === undefined || result.sig === undefined) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const item: IMutableItem = {
|
|
156
|
+
k: new Uint8Array(result.k),
|
|
157
|
+
salt,
|
|
158
|
+
seq: result.seq ?? 0,
|
|
159
|
+
v: new Uint8Array(result.v),
|
|
160
|
+
sig: new Uint8Array(result.sig),
|
|
161
|
+
};
|
|
162
|
+
return (await verifyMutableItem(item, publicKey, salt)) ? item.v : undefined;
|
|
163
|
+
} catch {
|
|
164
|
+
// allow-fallback: an aborted or failed lookup found nothing; the next source is tried
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
close() {
|
|
169
|
+
if (closed) return;
|
|
170
|
+
closed = true;
|
|
171
|
+
dht.destroy();
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|