cairn-p2p 0.2.0
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/README.md +43 -0
- package/dist/index.cjs +1883 -0
- package/dist/index.d.cts +572 -0
- package/dist/index.d.ts +572 -0
- package/dist/index.js +1827 -0
- package/eslint.config.js +24 -0
- package/package.json +54 -0
- package/src/channel.ts +277 -0
- package/src/config.ts +161 -0
- package/src/crypto/aead.ts +80 -0
- package/src/crypto/double-ratchet.ts +355 -0
- package/src/crypto/exchange.ts +51 -0
- package/src/crypto/hkdf.ts +33 -0
- package/src/crypto/identity.ts +84 -0
- package/src/crypto/index.ts +20 -0
- package/src/crypto/noise.ts +415 -0
- package/src/crypto/sas.ts +36 -0
- package/src/crypto/spake2.ts +169 -0
- package/src/discovery/index.ts +38 -0
- package/src/discovery/manager.ts +138 -0
- package/src/discovery/rendezvous.ts +189 -0
- package/src/discovery/tracker.ts +251 -0
- package/src/errors.ts +166 -0
- package/src/index.ts +57 -0
- package/src/mesh/index.ts +48 -0
- package/src/mesh/relay.ts +100 -0
- package/src/mesh/routing-table.ts +196 -0
- package/src/node.ts +619 -0
- package/src/pairing/adapter.ts +51 -0
- package/src/pairing/index.ts +40 -0
- package/src/pairing/link.ts +127 -0
- package/src/pairing/payload.ts +98 -0
- package/src/pairing/pin.ts +115 -0
- package/src/pairing/psk.ts +49 -0
- package/src/pairing/qr.ts +52 -0
- package/src/pairing/rate-limit.ts +134 -0
- package/src/pairing/sas-flow.ts +45 -0
- package/src/pairing/state-machine.ts +438 -0
- package/src/pairing/unpairing.ts +50 -0
- package/src/protocol/custom-handler.ts +52 -0
- package/src/protocol/envelope.ts +138 -0
- package/src/protocol/index.ts +36 -0
- package/src/protocol/message-types.ts +74 -0
- package/src/protocol/version.ts +98 -0
- package/src/server/index.ts +67 -0
- package/src/server/management.ts +285 -0
- package/src/server/store-forward.ts +266 -0
- package/src/session/backoff.ts +58 -0
- package/src/session/heartbeat.ts +79 -0
- package/src/session/index.ts +26 -0
- package/src/session/message-queue.ts +133 -0
- package/src/session/network-monitor.ts +130 -0
- package/src/session/state-machine.ts +122 -0
- package/src/session.ts +223 -0
- package/src/transport/fallback.ts +475 -0
- package/src/transport/index.ts +46 -0
- package/src/transport/libp2p-node.ts +158 -0
- package/src/transport/nat.ts +348 -0
- package/tests/conformance/cbor-vectors.test.ts +250 -0
- package/tests/integration/pairing-session.test.ts +317 -0
- package/tests/unit/config-api.test.ts +310 -0
- package/tests/unit/crypto.test.ts +407 -0
- package/tests/unit/discovery.test.ts +618 -0
- package/tests/unit/double-ratchet.test.ts +185 -0
- package/tests/unit/mesh.test.ts +349 -0
- package/tests/unit/noise.test.ts +346 -0
- package/tests/unit/pairing-extras.test.ts +402 -0
- package/tests/unit/pairing.test.ts +572 -0
- package/tests/unit/protocol.test.ts +438 -0
- package/tests/unit/reconnection.test.ts +402 -0
- package/tests/unit/scaffolding.test.ts +142 -0
- package/tests/unit/server.test.ts +492 -0
- package/tests/unit/sessions.test.ts +595 -0
- package/tests/unit/transport.test.ts +604 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +15 -0
package/eslint.config.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import tseslint from '@typescript-eslint/eslint-plugin';
|
|
2
|
+
import tsparser from '@typescript-eslint/parser';
|
|
3
|
+
|
|
4
|
+
export default [
|
|
5
|
+
{
|
|
6
|
+
files: ['src/**/*.ts'],
|
|
7
|
+
languageOptions: {
|
|
8
|
+
parser: tsparser,
|
|
9
|
+
parserOptions: {
|
|
10
|
+
ecmaVersion: 2022,
|
|
11
|
+
sourceType: 'module',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
plugins: {
|
|
15
|
+
'@typescript-eslint': tseslint,
|
|
16
|
+
},
|
|
17
|
+
rules: {
|
|
18
|
+
...tseslint.configs.recommended.rules,
|
|
19
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
20
|
+
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
21
|
+
'@typescript-eslint/no-explicit-any': 'warn',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cairn-p2p",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/moukrea/cairn.git",
|
|
8
|
+
"directory": "packages/ts/cairn-p2p"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/index.mjs",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"lint": "eslint src/",
|
|
21
|
+
"typecheck": "tsc --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@chainsafe/libp2p-noise": "^16.0.0",
|
|
25
|
+
"@libp2p/circuit-relay-v2": "^2.0.0",
|
|
26
|
+
"@libp2p/interface": "^2.0.0",
|
|
27
|
+
"@libp2p/kad-dht": "^13.0.0",
|
|
28
|
+
"@libp2p/mdns": "^11.0.0",
|
|
29
|
+
"@libp2p/tcp": "^10.0.0",
|
|
30
|
+
"@libp2p/webrtc": "^5.0.0",
|
|
31
|
+
"@libp2p/websockets": "^9.0.0",
|
|
32
|
+
"@libp2p/webtransport": "^5.0.0",
|
|
33
|
+
"@libp2p/yamux": "^7.0.0",
|
|
34
|
+
"@noble/ciphers": "^1.0.0",
|
|
35
|
+
"@noble/curves": "^1.4.0",
|
|
36
|
+
"@noble/ed25519": "^2.0.0",
|
|
37
|
+
"@noble/hashes": "^1.4.0",
|
|
38
|
+
"@stablelib/chacha20poly1305": "^1.0.0",
|
|
39
|
+
"cborg": "^4.0.0",
|
|
40
|
+
"eventemitter3": "^5.0.0",
|
|
41
|
+
"libp2p": "^2.0.0",
|
|
42
|
+
"uuid": "^10.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/uuid": "^10.0.0",
|
|
46
|
+
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
47
|
+
"@typescript-eslint/parser": "^8.0.0",
|
|
48
|
+
"eslint": "^9.0.0",
|
|
49
|
+
"js-yaml": "^4.1.1",
|
|
50
|
+
"tsup": "^8.0.0",
|
|
51
|
+
"typescript": "^5.5.0",
|
|
52
|
+
"vitest": "^2.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { CairnError } from './errors.js';
|
|
2
|
+
import { encode, decode } from 'cborg';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Channel constants
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/** Prefix for reserved cairn-internal channel names. */
|
|
9
|
+
export const RESERVED_CHANNEL_PREFIX = '__cairn_';
|
|
10
|
+
|
|
11
|
+
/** Reserved channel name for store-and-forward operations. */
|
|
12
|
+
export const CHANNEL_FORWARD = '__cairn_forward';
|
|
13
|
+
|
|
14
|
+
/** Message type code for ChannelInit (first message on a new stream). */
|
|
15
|
+
export const CHANNEL_INIT_TYPE = 0x0303;
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Channel name validation
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** Validate that a channel name is not reserved and not empty. */
|
|
22
|
+
export function validateChannelName(name: string): void {
|
|
23
|
+
if (!name) {
|
|
24
|
+
throw new CairnError('PROTOCOL', 'channel name must not be empty');
|
|
25
|
+
}
|
|
26
|
+
if (name.startsWith(RESERVED_CHANNEL_PREFIX)) {
|
|
27
|
+
throw new CairnError(
|
|
28
|
+
'PROTOCOL',
|
|
29
|
+
`channel name '${name}' uses reserved prefix '${RESERVED_CHANNEL_PREFIX}'`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Channel lifecycle states
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/** Channel lifecycle states. */
|
|
39
|
+
export type ChannelState = 'opening' | 'open' | 'rejected' | 'closed';
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Channel
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** A named channel multiplexed over a yamux stream. */
|
|
46
|
+
export class Channel {
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly streamId: number;
|
|
49
|
+
private _state: ChannelState;
|
|
50
|
+
readonly metadata?: Uint8Array;
|
|
51
|
+
|
|
52
|
+
constructor(name: string, streamId: number, metadata?: Uint8Array) {
|
|
53
|
+
this.name = name;
|
|
54
|
+
this.streamId = streamId;
|
|
55
|
+
this._state = 'opening';
|
|
56
|
+
this.metadata = metadata;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Current channel state. */
|
|
60
|
+
get state(): ChannelState {
|
|
61
|
+
return this._state;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Check if the channel is open and ready for data flow. */
|
|
65
|
+
isOpen(): boolean {
|
|
66
|
+
return this._state === 'open';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Transition to the Open state (accepted by remote). */
|
|
70
|
+
accept(): void {
|
|
71
|
+
if (this._state !== 'opening') {
|
|
72
|
+
throw new CairnError('PROTOCOL', `cannot accept channel '${this.name}' in state ${this._state}`);
|
|
73
|
+
}
|
|
74
|
+
this._state = 'open';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Transition to the Rejected state. */
|
|
78
|
+
reject(): void {
|
|
79
|
+
if (this._state !== 'opening') {
|
|
80
|
+
throw new CairnError('PROTOCOL', `cannot reject channel '${this.name}' in state ${this._state}`);
|
|
81
|
+
}
|
|
82
|
+
this._state = 'rejected';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Transition to the Closed state. */
|
|
86
|
+
close(): void {
|
|
87
|
+
if (this._state === 'closed') {
|
|
88
|
+
throw new CairnError('PROTOCOL', `channel '${this.name}' is already closed`);
|
|
89
|
+
}
|
|
90
|
+
this._state = 'closed';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// ChannelInit payload
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/** The first message sent on a newly opened yamux stream. */
|
|
99
|
+
export interface ChannelInit {
|
|
100
|
+
channelName: string;
|
|
101
|
+
metadata?: Uint8Array;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Encode a ChannelInit to CBOR bytes. */
|
|
105
|
+
export function encodeChannelInit(init: ChannelInit): Uint8Array {
|
|
106
|
+
const obj: Record<string, unknown> = { channel_name: init.channelName };
|
|
107
|
+
if (init.metadata) {
|
|
108
|
+
obj.metadata = init.metadata;
|
|
109
|
+
}
|
|
110
|
+
return encode(obj);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Decode a ChannelInit from CBOR bytes. */
|
|
114
|
+
export function decodeChannelInit(data: Uint8Array): ChannelInit {
|
|
115
|
+
const obj = decode(data) as Record<string, unknown>;
|
|
116
|
+
const channelName = obj.channel_name as string;
|
|
117
|
+
if (typeof channelName !== 'string') {
|
|
118
|
+
throw new CairnError('PROTOCOL', 'ChannelInit missing channel_name');
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
channelName,
|
|
122
|
+
metadata: obj.metadata instanceof Uint8Array ? obj.metadata : undefined,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// DataMessage / DataAck / DataNack
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/** Application data payload with reliable delivery semantics (0x0300). */
|
|
131
|
+
export interface DataMessage {
|
|
132
|
+
msgId: Uint8Array;
|
|
133
|
+
payload: Uint8Array;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Create a new DataMessage with a fresh UUID v7 identifier. */
|
|
137
|
+
export function createDataMessage(payload: Uint8Array): DataMessage {
|
|
138
|
+
const msgId = new Uint8Array(16);
|
|
139
|
+
crypto.getRandomValues(msgId);
|
|
140
|
+
// Set version 7 (bits 48-51)
|
|
141
|
+
msgId[6] = (msgId[6] & 0x0f) | 0x70;
|
|
142
|
+
// Set variant (bits 64-65)
|
|
143
|
+
msgId[8] = (msgId[8] & 0x3f) | 0x80;
|
|
144
|
+
// Embed timestamp in first 48 bits for ordering
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
msgId[0] = (now / 2 ** 40) & 0xff;
|
|
147
|
+
msgId[1] = (now / 2 ** 32) & 0xff;
|
|
148
|
+
msgId[2] = (now / 2 ** 24) & 0xff;
|
|
149
|
+
msgId[3] = (now / 2 ** 16) & 0xff;
|
|
150
|
+
msgId[4] = (now / 2 ** 8) & 0xff;
|
|
151
|
+
msgId[5] = now & 0xff;
|
|
152
|
+
return { msgId, payload };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Acknowledges successful receipt of a DataMessage (0x0301). */
|
|
156
|
+
export interface DataAck {
|
|
157
|
+
ackedMsgId: Uint8Array;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Negative acknowledgment, requesting retransmission (0x0302). */
|
|
161
|
+
export interface DataNack {
|
|
162
|
+
nackedMsgId: Uint8Array;
|
|
163
|
+
reason?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// ChannelManager
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/** Events emitted by the channel manager. */
|
|
171
|
+
export type ChannelEvent =
|
|
172
|
+
| { type: 'opened'; channelName: string; streamId: number; metadata?: Uint8Array }
|
|
173
|
+
| { type: 'accepted'; streamId: number }
|
|
174
|
+
| { type: 'rejected'; streamId: number; reason?: string }
|
|
175
|
+
| { type: 'data'; streamId: number; message: DataMessage }
|
|
176
|
+
| { type: 'closed'; streamId: number };
|
|
177
|
+
|
|
178
|
+
export type ChannelEventListener = (event: ChannelEvent) => void;
|
|
179
|
+
|
|
180
|
+
/** Manages channels within a session. */
|
|
181
|
+
export class ChannelManager {
|
|
182
|
+
private readonly _channels = new Map<number, Channel>();
|
|
183
|
+
private readonly _listeners: ChannelEventListener[] = [];
|
|
184
|
+
|
|
185
|
+
onEvent(listener: ChannelEventListener): void {
|
|
186
|
+
this._listeners.push(listener);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private emit(event: ChannelEvent): void {
|
|
190
|
+
for (const listener of this._listeners) {
|
|
191
|
+
listener(event);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Open a new channel on a given stream. Returns the ChannelInit payload. */
|
|
196
|
+
openChannel(name: string, streamId: number, metadata?: Uint8Array): ChannelInit {
|
|
197
|
+
validateChannelName(name);
|
|
198
|
+
|
|
199
|
+
if (this._channels.has(streamId)) {
|
|
200
|
+
throw new CairnError('PROTOCOL', `stream ${streamId} already has a channel`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const channel = new Channel(name, streamId, metadata);
|
|
204
|
+
this._channels.set(streamId, channel);
|
|
205
|
+
|
|
206
|
+
return { channelName: name, metadata };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Handle an incoming ChannelInit from a remote peer. */
|
|
210
|
+
handleChannelInit(streamId: number, init: ChannelInit): void {
|
|
211
|
+
if (this._channels.has(streamId)) {
|
|
212
|
+
throw new CairnError('PROTOCOL', `stream ${streamId} already has a channel`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const channel = new Channel(init.channelName, streamId, init.metadata);
|
|
216
|
+
this._channels.set(streamId, channel);
|
|
217
|
+
|
|
218
|
+
this.emit({
|
|
219
|
+
type: 'opened',
|
|
220
|
+
channelName: init.channelName,
|
|
221
|
+
streamId,
|
|
222
|
+
metadata: init.metadata,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Accept an incoming channel. */
|
|
227
|
+
acceptChannel(streamId: number): void {
|
|
228
|
+
const channel = this._channels.get(streamId);
|
|
229
|
+
if (!channel) {
|
|
230
|
+
throw new CairnError('PROTOCOL', `no channel on stream ${streamId}`);
|
|
231
|
+
}
|
|
232
|
+
channel.accept();
|
|
233
|
+
this.emit({ type: 'accepted', streamId });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Reject an incoming channel. */
|
|
237
|
+
rejectChannel(streamId: number, reason?: string): void {
|
|
238
|
+
const channel = this._channels.get(streamId);
|
|
239
|
+
if (!channel) {
|
|
240
|
+
throw new CairnError('PROTOCOL', `no channel on stream ${streamId}`);
|
|
241
|
+
}
|
|
242
|
+
channel.reject();
|
|
243
|
+
this.emit({ type: 'rejected', streamId, reason });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Handle incoming data on a channel. */
|
|
247
|
+
handleData(streamId: number, message: DataMessage): void {
|
|
248
|
+
const channel = this._channels.get(streamId);
|
|
249
|
+
if (!channel) {
|
|
250
|
+
throw new CairnError('PROTOCOL', `no channel on stream ${streamId}`);
|
|
251
|
+
}
|
|
252
|
+
if (!channel.isOpen()) {
|
|
253
|
+
throw new CairnError('PROTOCOL', `channel '${channel.name}' is not open (state: ${channel.state})`);
|
|
254
|
+
}
|
|
255
|
+
this.emit({ type: 'data', streamId, message });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Close a channel. */
|
|
259
|
+
closeChannel(streamId: number): void {
|
|
260
|
+
const channel = this._channels.get(streamId);
|
|
261
|
+
if (!channel) {
|
|
262
|
+
throw new CairnError('PROTOCOL', `no channel on stream ${streamId}`);
|
|
263
|
+
}
|
|
264
|
+
channel.close();
|
|
265
|
+
this.emit({ type: 'closed', streamId });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Get a channel by stream ID. */
|
|
269
|
+
getChannel(streamId: number): Channel | undefined {
|
|
270
|
+
return this._channels.get(streamId);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Get the number of tracked channels. */
|
|
274
|
+
get channelCount(): number {
|
|
275
|
+
return this._channels.size;
|
|
276
|
+
}
|
|
277
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage adapter interface for persisting identity, per-peer state, and
|
|
3
|
+
* per-session state. All values are opaque Uint8Array blobs.
|
|
4
|
+
*/
|
|
5
|
+
export interface StorageAdapter {
|
|
6
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
7
|
+
set(key: string, value: Uint8Array): Promise<void>;
|
|
8
|
+
delete(key: string): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Transport protocol in the fallback chain.
|
|
13
|
+
*/
|
|
14
|
+
export type TransportType = 'quic' | 'tcp' | 'websocket' | 'webtransport' | 'circuit-relay-v2';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Storage backend selection.
|
|
18
|
+
*
|
|
19
|
+
* - `'filesystem'` — encrypted at rest with passphrase (Node.js)
|
|
20
|
+
* - `'memory'` — ephemeral, for testing
|
|
21
|
+
* - A `StorageAdapter` instance for custom backends (keychains, HSMs, IndexedDB)
|
|
22
|
+
*/
|
|
23
|
+
export type StorageBackend = 'filesystem' | 'memory' | StorageAdapter;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* NAT type as detected by STUN probing.
|
|
27
|
+
*/
|
|
28
|
+
export type NatType =
|
|
29
|
+
| 'open'
|
|
30
|
+
| 'full_cone'
|
|
31
|
+
| 'restricted_cone'
|
|
32
|
+
| 'port_restricted_cone'
|
|
33
|
+
| 'symmetric'
|
|
34
|
+
| 'unknown';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Connection state machine states (spec/07 section 2).
|
|
38
|
+
*/
|
|
39
|
+
export type ConnectionState =
|
|
40
|
+
| 'connected'
|
|
41
|
+
| 'unstable'
|
|
42
|
+
| 'disconnected'
|
|
43
|
+
| 'reconnecting'
|
|
44
|
+
| 'suspended'
|
|
45
|
+
| 'reconnected'
|
|
46
|
+
| 'failed';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Cipher suite for AEAD encryption.
|
|
50
|
+
*/
|
|
51
|
+
export type CipherSuite = 'aes-256-gcm' | 'chacha20-poly1305';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Peer identity — 34-byte multihash (0x12, 0x20, <32-byte SHA-256>).
|
|
55
|
+
*/
|
|
56
|
+
export type PeerId = Uint8Array;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* TURN relay server credentials.
|
|
60
|
+
*/
|
|
61
|
+
export interface TurnServerConfig {
|
|
62
|
+
url: string;
|
|
63
|
+
username: string;
|
|
64
|
+
credential: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Exponential backoff parameters.
|
|
69
|
+
*/
|
|
70
|
+
export interface BackoffConfig {
|
|
71
|
+
initialDelay: number;
|
|
72
|
+
maxDelay: number;
|
|
73
|
+
factor: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reconnection and timeout policy (spec/11 section 2.2).
|
|
78
|
+
*/
|
|
79
|
+
export interface ReconnectionPolicy {
|
|
80
|
+
connectTimeout: number;
|
|
81
|
+
transportTimeout: number;
|
|
82
|
+
reconnectMaxDuration: number;
|
|
83
|
+
reconnectBackoff: BackoffConfig;
|
|
84
|
+
rendezvousPollInterval: number;
|
|
85
|
+
sessionExpiry: number;
|
|
86
|
+
pairingPayloadExpiry: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Mesh routing settings.
|
|
91
|
+
*/
|
|
92
|
+
export interface MeshSettings {
|
|
93
|
+
meshEnabled?: boolean;
|
|
94
|
+
maxHops?: number;
|
|
95
|
+
relayWilling?: boolean;
|
|
96
|
+
relayCapacity?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Top-level configuration object.
|
|
101
|
+
*
|
|
102
|
+
* Every field is optional — sensible defaults enable zero-config usage (Tier 0).
|
|
103
|
+
*/
|
|
104
|
+
export interface CairnConfig {
|
|
105
|
+
stunServers?: string[];
|
|
106
|
+
turnServers?: TurnServerConfig[];
|
|
107
|
+
signalingServers?: string[];
|
|
108
|
+
trackerUrls?: string[];
|
|
109
|
+
bootstrapNodes?: string[];
|
|
110
|
+
transportPreferences?: TransportType[];
|
|
111
|
+
reconnectionPolicy?: Partial<ReconnectionPolicy>;
|
|
112
|
+
meshSettings?: MeshSettings;
|
|
113
|
+
storageBackend?: StorageBackend;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Default STUN servers (Google, Cloudflare).
|
|
118
|
+
*/
|
|
119
|
+
export const DEFAULT_STUN_SERVERS: readonly string[] = [
|
|
120
|
+
'stun:stun.l.google.com:19302',
|
|
121
|
+
'stun:stun1.l.google.com:19302',
|
|
122
|
+
'stun:stun.cloudflare.com:3478',
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Default transport fallback order.
|
|
127
|
+
*/
|
|
128
|
+
export const DEFAULT_TRANSPORT_PREFERENCES: readonly TransportType[] = [
|
|
129
|
+
'quic',
|
|
130
|
+
'tcp',
|
|
131
|
+
'websocket',
|
|
132
|
+
'webtransport',
|
|
133
|
+
'circuit-relay-v2',
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Default reconnection policy values.
|
|
138
|
+
*/
|
|
139
|
+
export const DEFAULT_RECONNECTION_POLICY: Readonly<ReconnectionPolicy> = {
|
|
140
|
+
connectTimeout: 30_000,
|
|
141
|
+
transportTimeout: 10_000,
|
|
142
|
+
reconnectMaxDuration: 3_600_000,
|
|
143
|
+
reconnectBackoff: {
|
|
144
|
+
initialDelay: 1_000,
|
|
145
|
+
maxDelay: 60_000,
|
|
146
|
+
factor: 2.0,
|
|
147
|
+
},
|
|
148
|
+
rendezvousPollInterval: 30_000,
|
|
149
|
+
sessionExpiry: 86_400_000,
|
|
150
|
+
pairingPayloadExpiry: 300_000,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Default mesh settings.
|
|
155
|
+
*/
|
|
156
|
+
export const DEFAULT_MESH_SETTINGS: Readonly<Required<MeshSettings>> = {
|
|
157
|
+
meshEnabled: false,
|
|
158
|
+
maxHops: 3,
|
|
159
|
+
relayWilling: false,
|
|
160
|
+
relayCapacity: 10,
|
|
161
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { gcm } from '@noble/ciphers/aes';
|
|
2
|
+
import { ChaCha20Poly1305 } from '@stablelib/chacha20poly1305';
|
|
3
|
+
import { CairnError } from '../errors.js';
|
|
4
|
+
import type { CipherSuite } from '../config.js';
|
|
5
|
+
|
|
6
|
+
/** Nonce size for both ciphers: 12 bytes. */
|
|
7
|
+
export const NONCE_SIZE = 12;
|
|
8
|
+
/** Key size for both ciphers: 32 bytes. */
|
|
9
|
+
export const KEY_SIZE = 32;
|
|
10
|
+
/** Authentication tag size for both ciphers: 16 bytes. */
|
|
11
|
+
export const TAG_SIZE = 16;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Encrypt plaintext with associated data using the specified cipher.
|
|
15
|
+
*
|
|
16
|
+
* Returns ciphertext with appended authentication tag.
|
|
17
|
+
*/
|
|
18
|
+
export function aeadEncrypt(
|
|
19
|
+
cipher: CipherSuite,
|
|
20
|
+
key: Uint8Array,
|
|
21
|
+
nonce: Uint8Array,
|
|
22
|
+
plaintext: Uint8Array,
|
|
23
|
+
aad: Uint8Array,
|
|
24
|
+
): Uint8Array {
|
|
25
|
+
if (key.length !== KEY_SIZE) {
|
|
26
|
+
throw new CairnError('CRYPTO', `AEAD key must be ${KEY_SIZE} bytes, got ${key.length}`);
|
|
27
|
+
}
|
|
28
|
+
if (nonce.length !== NONCE_SIZE) {
|
|
29
|
+
throw new CairnError('CRYPTO', `AEAD nonce must be ${NONCE_SIZE} bytes, got ${nonce.length}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
if (cipher === 'aes-256-gcm') {
|
|
34
|
+
const aes = gcm(key, nonce, aad);
|
|
35
|
+
return aes.encrypt(plaintext);
|
|
36
|
+
} else {
|
|
37
|
+
const chacha = new ChaCha20Poly1305(key);
|
|
38
|
+
return chacha.seal(nonce, plaintext, aad);
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {
|
|
41
|
+
throw new CairnError('CRYPTO', `AEAD encrypt error: ${e}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decrypt ciphertext with associated data using the specified cipher.
|
|
47
|
+
*
|
|
48
|
+
* Returns plaintext on success. Throws CairnError if authentication fails.
|
|
49
|
+
*/
|
|
50
|
+
export function aeadDecrypt(
|
|
51
|
+
cipher: CipherSuite,
|
|
52
|
+
key: Uint8Array,
|
|
53
|
+
nonce: Uint8Array,
|
|
54
|
+
ciphertext: Uint8Array,
|
|
55
|
+
aad: Uint8Array,
|
|
56
|
+
): Uint8Array {
|
|
57
|
+
if (key.length !== KEY_SIZE) {
|
|
58
|
+
throw new CairnError('CRYPTO', `AEAD key must be ${KEY_SIZE} bytes, got ${key.length}`);
|
|
59
|
+
}
|
|
60
|
+
if (nonce.length !== NONCE_SIZE) {
|
|
61
|
+
throw new CairnError('CRYPTO', `AEAD nonce must be ${NONCE_SIZE} bytes, got ${nonce.length}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (cipher === 'aes-256-gcm') {
|
|
66
|
+
const aes = gcm(key, nonce, aad);
|
|
67
|
+
return aes.decrypt(ciphertext);
|
|
68
|
+
} else {
|
|
69
|
+
const chacha = new ChaCha20Poly1305(key);
|
|
70
|
+
const result = chacha.open(nonce, ciphertext, aad);
|
|
71
|
+
if (result === null) {
|
|
72
|
+
throw new CairnError('CRYPTO', 'ChaCha20-Poly1305 authentication failed');
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (e instanceof CairnError) throw e;
|
|
78
|
+
throw new CairnError('CRYPTO', `AEAD decrypt error: ${e}`);
|
|
79
|
+
}
|
|
80
|
+
}
|