@floegence/flowersec-core 0.21.1 → 0.22.1

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.
@@ -0,0 +1,119 @@
1
+ import { ed25519 } from "@noble/curves/ed25519";
2
+ import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
3
+ export const TOKEN_PREFIX = "FST2";
4
+ export class TokenError extends Error {
5
+ code;
6
+ constructor(code, message = code) {
7
+ super(message);
8
+ this.code = code;
9
+ this.name = "TokenError";
10
+ }
11
+ }
12
+ export function signToken(signingSeed, payload) {
13
+ if (signingSeed.length !== 32)
14
+ throw new TokenError("invalid_signing_key");
15
+ const normalized = normalizePayload(payload, true);
16
+ const payloadJSON = new TextEncoder().encode(JSON.stringify(normalized));
17
+ const signedText = `${TOKEN_PREFIX}.${base64urlEncode(payloadJSON)}`;
18
+ const signature = ed25519.sign(new TextEncoder().encode(signedText), signingSeed);
19
+ return `${signedText}.${base64urlEncode(signature)}`;
20
+ }
21
+ export function parseToken(token) {
22
+ const parts = token.split(".");
23
+ if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX)
24
+ throw new TokenError("invalid_format");
25
+ try {
26
+ const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1])));
27
+ return {
28
+ payload,
29
+ signed: new TextEncoder().encode(`${TOKEN_PREFIX}.${parts[1]}`),
30
+ signature: base64urlDecode(parts[2]),
31
+ };
32
+ }
33
+ catch (error) {
34
+ if (error instanceof SyntaxError)
35
+ throw new TokenError("invalid_json");
36
+ throw new TokenError("invalid_base64url");
37
+ }
38
+ }
39
+ export function verifyToken(token, keys, options = {}) {
40
+ const parsed = parseToken(token);
41
+ const payload = normalizePayload(parsed.payload, false);
42
+ const publicKey = typeof keys === "function" ? keys(payload.kid) : keys.get(payload.kid);
43
+ if (publicKey == null)
44
+ throw new TokenError("unknown_kid");
45
+ if (publicKey.length !== 32 || parsed.signature.length !== 64 || !ed25519.verify(parsed.signature, parsed.signed, publicKey)) {
46
+ throw new TokenError("invalid_signature");
47
+ }
48
+ if (options.audience !== undefined && !constantTimeTextEqual(payload.aud, options.audience))
49
+ throw new TokenError("invalid_audience");
50
+ if (options.issuer !== undefined && !constantTimeTextEqual(payload.iss ?? "", options.issuer))
51
+ throw new TokenError("invalid_issuer");
52
+ const skewMs = options.clockSkewMs ?? 0;
53
+ if (!Number.isFinite(skewMs) || skewMs < 0)
54
+ throw new TokenError("invalid_clock_skew");
55
+ const skewSeconds = Math.ceil(skewMs / 1000);
56
+ const now = options.nowUnixS ?? Math.floor(Date.now() / 1000);
57
+ if (!Number.isSafeInteger(now))
58
+ throw new TokenError("invalid_time");
59
+ if (payload.iat > now + skewSeconds)
60
+ throw new TokenError("iat_in_future");
61
+ if (payload.init_exp < now - skewSeconds)
62
+ throw new TokenError("init_expired");
63
+ if (payload.exp < now - skewSeconds)
64
+ throw new TokenError("expired");
65
+ return payload;
66
+ }
67
+ export function equalSignedTokenPart(left, right) {
68
+ const leftPart = left.lastIndexOf(".");
69
+ const rightPart = right.lastIndexOf(".");
70
+ if (leftPart <= 0 || rightPart <= 0)
71
+ return false;
72
+ return constantTimeTextEqual(left.slice(0, leftPart), right.slice(0, rightPart));
73
+ }
74
+ function normalizePayload(input, signing) {
75
+ const kid = String(input?.kid ?? "").trim();
76
+ const aud = String(input?.aud ?? "").trim();
77
+ const iss = String(input?.iss ?? "").trim();
78
+ const channelId = String(input?.channel_id ?? "").trim();
79
+ const tokenId = String(input?.token_id ?? "").trim();
80
+ if (kid === "" || tokenId === "" || (signing && aud === ""))
81
+ throw new TokenError("invalid_format");
82
+ if (channelId === "" || new TextEncoder().encode(channelId).length > 256)
83
+ throw new TokenError("invalid_format");
84
+ if (input.role !== 1 && input.role !== 2)
85
+ throw new TokenError("invalid_format");
86
+ for (const [name, value] of [["init_exp", input.init_exp], ["iat", input.iat], ["exp", input.exp], ["idle_timeout_seconds", input.idle_timeout_seconds]]) {
87
+ if (!Number.isSafeInteger(value))
88
+ throw new TokenError("invalid_format", `invalid ${name}`);
89
+ }
90
+ if (input.idle_timeout_seconds <= 0)
91
+ throw new TokenError("invalid_idle_timeout");
92
+ if (signing && (input.init_exp <= 0 || input.iat <= 0 || input.exp <= 0))
93
+ throw new TokenError("invalid_format");
94
+ if (input.exp > input.init_exp)
95
+ throw new TokenError("exp_after_init");
96
+ if (input.iat > input.exp)
97
+ throw new TokenError("invalid_format");
98
+ return {
99
+ kid,
100
+ aud,
101
+ ...(iss === "" ? {} : { iss }),
102
+ channel_id: channelId,
103
+ role: input.role,
104
+ token_id: tokenId,
105
+ init_exp: input.init_exp,
106
+ idle_timeout_seconds: input.idle_timeout_seconds,
107
+ iat: input.iat,
108
+ exp: input.exp,
109
+ };
110
+ }
111
+ function constantTimeTextEqual(left, right) {
112
+ const a = new TextEncoder().encode(left);
113
+ const b = new TextEncoder().encode(right);
114
+ let diff = a.length ^ b.length;
115
+ const length = Math.max(a.length, b.length);
116
+ for (let index = 0; index < length; index++)
117
+ diff |= (a[index] ?? 0) ^ (b[index] ?? 0);
118
+ return diff === 0;
119
+ }
@@ -0,0 +1,46 @@
1
+ export declare const SDK_DEFAULTS: Readonly<{
2
+ transport: Readonly<{
3
+ connectTimeoutMs: 10000;
4
+ handshakeTimeoutMs: 10000;
5
+ handshakeClockSkewMs: 30000;
6
+ }>;
7
+ e2ee: Readonly<{
8
+ maxHandshakePayloadBytes: number;
9
+ maxRecordBytes: number;
10
+ outboundRecordChunkBytes: number;
11
+ maxOutboundBufferedBytes: number;
12
+ }>;
13
+ yamux: Readonly<{
14
+ maxActiveStreams: 64;
15
+ maxInboundStreams: 32;
16
+ maxFrameBytes: number;
17
+ preferredOutboundFrameBytes: number;
18
+ maxStreamReceiveBytes: number;
19
+ maxSessionReceiveBytes: number;
20
+ }>;
21
+ rpc: Readonly<{
22
+ maxJsonFrameBytes: number;
23
+ maxConcurrentRequests: 32;
24
+ maxQueuedRequests: 128;
25
+ maxQueuedNotifications: 128;
26
+ }>;
27
+ controlplane: Readonly<{
28
+ maxRequestBodyBytes: number;
29
+ maxResponseBodyBytes: number;
30
+ }>;
31
+ proxy: Readonly<{
32
+ maxJsonFrameBytes: number;
33
+ maxChunkBytes: number;
34
+ maxBodyBytes: number;
35
+ maxWsFrameBytes: number;
36
+ defaultTimeoutMs: 30000;
37
+ maxTimeoutMs: 300000;
38
+ }>;
39
+ reconnect: Readonly<{
40
+ maxAttempts: 5;
41
+ initialDelayMs: 500;
42
+ maxDelayMs: 10000;
43
+ factor: 1.8;
44
+ jitterRatio: 0.2;
45
+ }>;
46
+ }>;
@@ -0,0 +1,46 @@
1
+ export const SDK_DEFAULTS = Object.freeze({
2
+ transport: Object.freeze({
3
+ connectTimeoutMs: 10_000,
4
+ handshakeTimeoutMs: 10_000,
5
+ handshakeClockSkewMs: 30_000,
6
+ }),
7
+ e2ee: Object.freeze({
8
+ maxHandshakePayloadBytes: 8 * 1024,
9
+ maxRecordBytes: 1024 * 1024,
10
+ outboundRecordChunkBytes: 64 * 1024,
11
+ maxOutboundBufferedBytes: 4 * 1024 * 1024,
12
+ }),
13
+ yamux: Object.freeze({
14
+ maxActiveStreams: 64,
15
+ maxInboundStreams: 32,
16
+ maxFrameBytes: 256 * 1024,
17
+ preferredOutboundFrameBytes: 64 * 1024,
18
+ maxStreamReceiveBytes: 256 * 1024,
19
+ maxSessionReceiveBytes: 16 * 1024 * 1024,
20
+ }),
21
+ rpc: Object.freeze({
22
+ maxJsonFrameBytes: 1024 * 1024,
23
+ maxConcurrentRequests: 32,
24
+ maxQueuedRequests: 128,
25
+ maxQueuedNotifications: 128,
26
+ }),
27
+ controlplane: Object.freeze({
28
+ maxRequestBodyBytes: 32 * 1024,
29
+ maxResponseBodyBytes: 1024 * 1024,
30
+ }),
31
+ proxy: Object.freeze({
32
+ maxJsonFrameBytes: 1024 * 1024,
33
+ maxChunkBytes: 256 * 1024,
34
+ maxBodyBytes: 64 * 1024 * 1024,
35
+ maxWsFrameBytes: 1024 * 1024,
36
+ defaultTimeoutMs: 30_000,
37
+ maxTimeoutMs: 300_000,
38
+ }),
39
+ reconnect: Object.freeze({
40
+ maxAttempts: 5,
41
+ initialDelayMs: 500,
42
+ maxDelayMs: 10_000,
43
+ factor: 1.8,
44
+ jitterRatio: 0.2,
45
+ }),
46
+ });
@@ -0,0 +1,83 @@
1
+ import { type TransportSecurityPolicy } from "../client-connect/transportSecurity.js";
2
+ import { serverHandshake, ServerHandshakeCache, type Suite } from "../e2ee/handshake.js";
3
+ import { type YamuxLimits } from "../yamux/session.js";
4
+ import type { YamuxStream } from "../yamux/stream.js";
5
+ import { type RpcRouter, type RpcServerOptions } from "../rpc/server.js";
6
+ import { type WebSocketLike, type WebSocketLimits } from "../ws-client/binaryTransport.js";
7
+ export type { Suite } from "../e2ee/handshake.js";
8
+ export type EndpointPath = "direct" | "tunnel";
9
+ export type DirectHandshakeInit = Readonly<{
10
+ channelId: string;
11
+ version: number;
12
+ suite: Suite;
13
+ clientFeatures: number;
14
+ }>;
15
+ export type DirectHandshakeCredential = Readonly<{
16
+ psk: Uint8Array | string;
17
+ initExpireAtUnixS: number;
18
+ commitAuthenticated?: () => void | Promise<void>;
19
+ }>;
20
+ export type DirectCredentialResolver = (init: DirectHandshakeInit) => DirectHandshakeCredential | Promise<DirectHandshakeCredential>;
21
+ export type EndpointOptions = Readonly<{
22
+ signal?: AbortSignal;
23
+ handshakeTimeoutMs?: number;
24
+ handshakeClockSkewMs?: number;
25
+ serverFeatures?: number;
26
+ maxHandshakePayload?: number;
27
+ maxRecordBytes?: number;
28
+ maxBufferedBytes?: number;
29
+ maxOutboundBufferedBytes?: number;
30
+ outboundRecordChunkBytes?: number;
31
+ webSocketLimits?: Partial<WebSocketLimits>;
32
+ yamuxLimits?: Partial<YamuxLimits>;
33
+ handshakeCache?: ServerHandshakeCache;
34
+ }>;
35
+ export type DirectAcceptOptions = EndpointOptions & Readonly<{
36
+ secureTransport?: boolean;
37
+ transportSecurityPolicy?: TransportSecurityPolicy;
38
+ }>;
39
+ export type TunnelEndpointOptions = EndpointOptions & Readonly<{
40
+ origin: string;
41
+ connectTimeoutMs?: number;
42
+ endpointInstanceId?: string;
43
+ wsFactory: (url: string, origin: string) => WebSocketLike;
44
+ transportSecurityPolicy?: TransportSecurityPolicy;
45
+ }>;
46
+ export type EndpointStream = Readonly<{
47
+ kind: string;
48
+ stream: YamuxStream;
49
+ }>;
50
+ export declare class Session {
51
+ private readonly secure;
52
+ private readonly mux;
53
+ readonly path: EndpointPath;
54
+ readonly endpointInstanceId: string | undefined;
55
+ private readonly streams;
56
+ private readonly waiters;
57
+ private terminalError;
58
+ private constructor();
59
+ static create(path: EndpointPath, secure: Awaited<ReturnType<typeof serverHandshake>>, options?: Readonly<{
60
+ yamuxLimits?: Partial<YamuxLimits>;
61
+ endpointInstanceId?: string;
62
+ }>): Session;
63
+ openStream(kind: string, options?: Readonly<{
64
+ signal?: AbortSignal;
65
+ }>): Promise<YamuxStream>;
66
+ acceptStream(options?: Readonly<{
67
+ signal?: AbortSignal;
68
+ }>): Promise<EndpointStream>;
69
+ serveRPC(router: RpcRouter, options?: RpcServerOptions & Readonly<{
70
+ signal?: AbortSignal;
71
+ }>): Promise<void>;
72
+ probeLiveness(timeoutMs?: 10000): Promise<number>;
73
+ close(): void;
74
+ private pushStream;
75
+ private acceptRawStream;
76
+ private fail;
77
+ }
78
+ export declare function acceptDirect(websocket: WebSocketLike, handshake: Readonly<{
79
+ channelId: string;
80
+ suite: Suite;
81
+ }> & DirectHandshakeCredential, options?: DirectAcceptOptions): Promise<Session>;
82
+ export declare function acceptDirectResolved(websocket: WebSocketLike, resolver: DirectCredentialResolver, options?: DirectAcceptOptions): Promise<Session>;
83
+ export declare function connectTunnel(grantInput: unknown, options: TunnelEndpointOptions): Promise<Session>;
@@ -0,0 +1,384 @@
1
+ import { Role as ControlRole, assertChannelInitGrant } from "../gen/flowersec/controlplane/v1.gen.js";
2
+ import { Role as TunnelRole } from "../gen/flowersec/tunnel/v1.gen.js";
3
+ import { assertTunnelGrantContract, assertValidPSK, prepareChannelId } from "../client-connect/contract.js";
4
+ import { enforceTransportSecurity } from "../client-connect/transportSecurity.js";
5
+ import { SDK_DEFAULTS } from "../defaults.js";
6
+ import { serverHandshake, ServerHandshakeCache } from "../e2ee/handshake.js";
7
+ import { decodeHandshakeFrame } from "../e2ee/framing.js";
8
+ import { HANDSHAKE_TYPE_INIT, PROTOCOL_VERSION } from "../e2ee/constants.js";
9
+ import { readStreamHello, writeStreamHello } from "../streamhello/streamHello.js";
10
+ import { ByteReader } from "../yamux/byteReader.js";
11
+ import { YamuxSession } from "../yamux/session.js";
12
+ import { RpcServer } from "../rpc/server.js";
13
+ import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
14
+ import { AbortError, FlowersecError } from "../utils/errors.js";
15
+ import { WebSocketBinaryTransport } from "../ws-client/binaryTransport.js";
16
+ export class Session {
17
+ secure;
18
+ mux;
19
+ path;
20
+ endpointInstanceId;
21
+ streams = [];
22
+ waiters = [];
23
+ terminalError;
24
+ constructor(path, secure, mux, endpointInstanceId) {
25
+ this.secure = secure;
26
+ this.mux = mux;
27
+ this.path = path;
28
+ this.endpointInstanceId = endpointInstanceId;
29
+ }
30
+ static create(path, secure, options = {}) {
31
+ let session;
32
+ const mux = new YamuxSession({
33
+ read: () => secure.read(),
34
+ write: (bytes) => secure.write(bytes),
35
+ close: () => secure.close(),
36
+ }, {
37
+ client: false,
38
+ ...(options.yamuxLimits === undefined ? {} : { limits: options.yamuxLimits }),
39
+ onIncomingStream: (stream) => session.pushStream(stream),
40
+ onTerminal: (error) => session.fail(error),
41
+ });
42
+ session = new Session(path, secure, mux, options.endpointInstanceId);
43
+ return session;
44
+ }
45
+ async openStream(kind, options = {}) {
46
+ const stream = await this.mux.openStream(options);
47
+ try {
48
+ await writeStreamHello((bytes) => stream.write(bytes), normalizeStreamKind(kind, this.path));
49
+ return stream;
50
+ }
51
+ catch (error) {
52
+ await stream.reset(asError(error));
53
+ throw new FlowersecError({ path: this.path, stage: "rpc", code: "stream_hello_failed", message: "failed to write stream hello", cause: error });
54
+ }
55
+ }
56
+ async acceptStream(options = {}) {
57
+ const stream = await this.acceptRawStream(options.signal);
58
+ try {
59
+ const reader = new ByteReader(() => stream.read());
60
+ const hello = await readStreamHello((length) => reader.readExactly(length));
61
+ return { kind: hello.kind, stream };
62
+ }
63
+ catch (error) {
64
+ await stream.reset(asError(error));
65
+ throw new FlowersecError({ path: this.path, stage: "rpc", code: "stream_hello_failed", message: "failed to read stream hello", cause: error });
66
+ }
67
+ }
68
+ async serveRPC(router, options = {}) {
69
+ while (true) {
70
+ const accepted = await this.acceptStream(options.signal === undefined ? {} : { signal: options.signal });
71
+ if (accepted.kind !== "rpc") {
72
+ await accepted.stream.reset(new Error(`unexpected stream kind ${accepted.kind}`));
73
+ continue;
74
+ }
75
+ const reader = new ByteReader(() => accepted.stream.read());
76
+ const server = new RpcServer({
77
+ readExactly: (length) => reader.readExactly(length),
78
+ write: (bytes) => accepted.stream.write(bytes),
79
+ close: (error) => { void accepted.stream.reset(asError(error)); },
80
+ }, options, router);
81
+ await server.serve(options.signal);
82
+ return;
83
+ }
84
+ }
85
+ probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
86
+ return this.mux.probeLiveness(timeoutMs);
87
+ }
88
+ close() {
89
+ this.fail(new Error("endpoint session closed"));
90
+ this.mux.close();
91
+ this.secure.close();
92
+ }
93
+ pushStream(stream) {
94
+ const waiter = this.waiters.shift();
95
+ if (waiter == null) {
96
+ this.streams.push(stream);
97
+ return;
98
+ }
99
+ cleanupWaiter(waiter);
100
+ waiter.resolve(stream);
101
+ }
102
+ acceptRawStream(signal) {
103
+ if (signal?.aborted)
104
+ return Promise.reject(new AbortError("accept stream aborted"));
105
+ if (this.terminalError != null)
106
+ return Promise.reject(this.terminalError);
107
+ const stream = this.streams.shift();
108
+ if (stream != null)
109
+ return Promise.resolve(stream);
110
+ return new Promise((resolve, reject) => {
111
+ const waiter = { resolve, reject, ...(signal === undefined ? {} : { signal }) };
112
+ waiter.onAbort = () => {
113
+ const index = this.waiters.indexOf(waiter);
114
+ if (index >= 0)
115
+ this.waiters.splice(index, 1);
116
+ cleanupWaiter(waiter);
117
+ reject(new AbortError("accept stream aborted"));
118
+ };
119
+ signal?.addEventListener("abort", waiter.onAbort, { once: true });
120
+ this.waiters.push(waiter);
121
+ });
122
+ }
123
+ fail(error) {
124
+ if (this.terminalError != null)
125
+ return;
126
+ this.terminalError = error;
127
+ for (const waiter of this.waiters.splice(0)) {
128
+ cleanupWaiter(waiter);
129
+ waiter.reject(error);
130
+ }
131
+ }
132
+ }
133
+ export async function acceptDirect(websocket, handshake, options = {}) {
134
+ await enforceIncomingDirectTransport(options);
135
+ const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
136
+ return await establishSession("direct", transport, handshake, options);
137
+ }
138
+ export async function acceptDirectResolved(websocket, resolver, options = {}) {
139
+ await enforceIncomingDirectTransport(options);
140
+ const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
141
+ const first = await transport.readBinary(readOptions(options));
142
+ let init;
143
+ try {
144
+ const decoded = decodeHandshakeFrame(first, options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes);
145
+ if (decoded.handshakeType !== HANDSHAKE_TYPE_INIT)
146
+ throw new Error("expected handshake init");
147
+ init = JSON.parse(new TextDecoder().decode(decoded.payloadJsonUtf8));
148
+ if (init.version !== PROTOCOL_VERSION || init.role !== 1 || (init.suite !== 1 && init.suite !== 2))
149
+ throw new Error("invalid handshake init");
150
+ }
151
+ catch (error) {
152
+ transport.close();
153
+ throw new FlowersecError({ path: "direct", stage: "handshake", code: "handshake_failed", message: "invalid handshake init", cause: error });
154
+ }
155
+ let credential;
156
+ try {
157
+ credential = await resolver({
158
+ channelId: prepareChannelId(init.channel_id, "direct"),
159
+ version: init.version,
160
+ suite: init.suite,
161
+ clientFeatures: init.client_features >>> 0,
162
+ });
163
+ }
164
+ catch (error) {
165
+ transport.close();
166
+ throw new FlowersecError({ path: "direct", stage: "validate", code: "resolve_failed", message: "credential resolution failed", cause: error });
167
+ }
168
+ const replay = new PrefetchedTransport(transport, first);
169
+ return await establishSession("direct", replay, {
170
+ channelId: init.channel_id,
171
+ suite: init.suite,
172
+ ...credential,
173
+ }, options);
174
+ }
175
+ export async function connectTunnel(grantInput, options) {
176
+ let grant;
177
+ try {
178
+ grant = assertChannelInitGrant(unwrapServerGrant(grantInput));
179
+ }
180
+ catch (error) {
181
+ throw new FlowersecError({ path: "tunnel", stage: "validate", code: "invalid_input", message: "invalid ChannelInitGrant", cause: error });
182
+ }
183
+ assertTunnelGrantContract(grant, ControlRole.Role_server);
184
+ const tunnelUrl = grant.tunnel_url.trim();
185
+ if (tunnelUrl === "")
186
+ throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_tunnel_url", message: "missing tunnel_url" });
187
+ if (grant.token.trim() === "")
188
+ throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_token", message: "missing token" });
189
+ if (grant.channel_init_expire_at_unix_s <= 0)
190
+ throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_init_exp", message: "missing channel init expiry" });
191
+ const origin = options.origin.trim();
192
+ if (origin === "")
193
+ throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_origin", message: "missing origin" });
194
+ await enforceTransportSecurity({ rawUrl: tunnelUrl, path: "tunnel", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
195
+ const endpointInstanceId = normalizeEndpointInstanceId(options.endpointInstanceId);
196
+ const websocket = options.wsFactory(tunnelUrl, origin);
197
+ const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
198
+ try {
199
+ await waitForOpen(websocket, options.connectTimeoutMs ?? SDK_DEFAULTS.transport.connectTimeoutMs, options.signal);
200
+ const attach = {
201
+ v: 1,
202
+ channel_id: prepareChannelId(grant.channel_id, "tunnel"),
203
+ role: TunnelRole.Role_server,
204
+ token: grant.token.trim(),
205
+ endpoint_instance_id: endpointInstanceId,
206
+ };
207
+ websocket.send(JSON.stringify(attach));
208
+ return await establishSession("tunnel", transport, {
209
+ channelId: grant.channel_id,
210
+ suite: grant.default_suite,
211
+ psk: assertValidPSK(grant.e2ee_psk_b64u, "tunnel"),
212
+ initExpireAtUnixS: grant.channel_init_expire_at_unix_s,
213
+ }, options, endpointInstanceId);
214
+ }
215
+ catch (error) {
216
+ transport.close();
217
+ if (error instanceof FlowersecError)
218
+ throw error;
219
+ throw new FlowersecError({ path: "tunnel", stage: "connect", code: "dial_failed", message: "endpoint tunnel connect failed", cause: error });
220
+ }
221
+ }
222
+ async function establishSession(path, transport, handshake, options, endpointInstanceId) {
223
+ let psk;
224
+ try {
225
+ psk = normalizePSK(handshake.psk, path);
226
+ }
227
+ catch (error) {
228
+ transport.close();
229
+ throw error;
230
+ }
231
+ try {
232
+ const secure = await serverHandshake(transport, options.handshakeCache ?? new ServerHandshakeCache(), {
233
+ channelId: prepareChannelId(handshake.channelId, path),
234
+ suite: handshake.suite,
235
+ psk,
236
+ serverFeatures: options.serverFeatures ?? 0,
237
+ initExpireAtUnixS: handshake.initExpireAtUnixS,
238
+ clockSkewSeconds: Math.ceil((options.handshakeClockSkewMs ?? SDK_DEFAULTS.transport.handshakeClockSkewMs) / 1000),
239
+ maxHandshakePayload: options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes,
240
+ maxRecordBytes: options.maxRecordBytes ?? SDK_DEFAULTS.e2ee.maxRecordBytes,
241
+ outboundRecordChunkBytes: options.outboundRecordChunkBytes ?? SDK_DEFAULTS.e2ee.outboundRecordChunkBytes,
242
+ maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
243
+ maxOutboundBufferedBytes: options.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
244
+ timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
245
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
246
+ });
247
+ try {
248
+ await handshake.commitAuthenticated?.();
249
+ }
250
+ catch (error) {
251
+ secure.close();
252
+ throw new FlowersecError({ path, stage: "handshake", code: "credential_commit_failed", message: "credential commit failed", cause: error });
253
+ }
254
+ return Session.create(path, secure, {
255
+ ...(options.yamuxLimits === undefined ? {} : { yamuxLimits: options.yamuxLimits }),
256
+ ...(endpointInstanceId === undefined ? {} : { endpointInstanceId }),
257
+ });
258
+ }
259
+ catch (error) {
260
+ transport.close();
261
+ if (error instanceof FlowersecError)
262
+ throw error;
263
+ throw new FlowersecError({ path, stage: "handshake", code: "handshake_failed", message: "endpoint handshake failed", cause: error });
264
+ }
265
+ finally {
266
+ psk.fill(0);
267
+ }
268
+ }
269
+ class PrefetchedTransport {
270
+ inner;
271
+ first;
272
+ constructor(inner, first) {
273
+ this.inner = inner;
274
+ this.first = first;
275
+ }
276
+ readBinary(options) {
277
+ if (this.first != null) {
278
+ const first = this.first;
279
+ this.first = undefined;
280
+ return Promise.resolve(first);
281
+ }
282
+ return this.inner.readBinary(options);
283
+ }
284
+ writeBinary(frame, options) {
285
+ return this.inner.writeBinary(frame, options);
286
+ }
287
+ close() {
288
+ this.inner.close();
289
+ }
290
+ }
291
+ function normalizePSK(input, path) {
292
+ try {
293
+ const psk = typeof input === "string" ? base64urlDecode(input.trim()) : input.slice();
294
+ if (psk.length !== 32)
295
+ throw new Error("psk must be 32 bytes");
296
+ return psk;
297
+ }
298
+ catch (error) {
299
+ throw new FlowersecError({ path, stage: "validate", code: "invalid_psk", message: "invalid psk", cause: error });
300
+ }
301
+ }
302
+ function normalizeEndpointInstanceId(input) {
303
+ const value = input ?? randomEndpointInstanceId();
304
+ try {
305
+ const bytes = base64urlDecode(value);
306
+ if (bytes.length < 16 || bytes.length > 32)
307
+ throw new Error("endpoint instance ID must decode to 16..32 bytes");
308
+ }
309
+ catch (error) {
310
+ throw new FlowersecError({ path: "tunnel", stage: "validate", code: "invalid_endpoint_instance_id", message: "invalid endpoint instance ID", cause: error });
311
+ }
312
+ return value;
313
+ }
314
+ function randomEndpointInstanceId() {
315
+ const bytes = new Uint8Array(24);
316
+ crypto.getRandomValues(bytes);
317
+ return base64urlEncode(bytes);
318
+ }
319
+ function normalizeStreamKind(kind, path) {
320
+ const value = kind.trim();
321
+ if (value === "")
322
+ throw new FlowersecError({ path, stage: "validate", code: "missing_stream_kind", message: "missing stream kind" });
323
+ return value;
324
+ }
325
+ function unwrapServerGrant(input) {
326
+ if (typeof input !== "object" || input == null || Array.isArray(input))
327
+ return input;
328
+ const record = input;
329
+ return record["grant_server"] ?? input;
330
+ }
331
+ function webSocketTransportOptions(options) {
332
+ return options.webSocketLimits === undefined ? {} : { webSocketLimits: options.webSocketLimits };
333
+ }
334
+ function readOptions(options) {
335
+ return {
336
+ timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
337
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
338
+ };
339
+ }
340
+ async function enforceIncomingDirectTransport(options) {
341
+ const rawUrl = options.secureTransport === false ? "ws://127.0.0.1/" : "wss://127.0.0.1/";
342
+ await enforceTransportSecurity({ rawUrl, path: "direct", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
343
+ }
344
+ function waitForOpen(websocket, timeoutMs, signal) {
345
+ if (websocket.readyState === 1)
346
+ return Promise.resolve();
347
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
348
+ return Promise.reject(new RangeError("connectTimeoutMs must be non-negative"));
349
+ return new Promise((resolve, reject) => {
350
+ let timer;
351
+ const cleanup = () => {
352
+ if (timer != null)
353
+ clearTimeout(timer);
354
+ websocket.removeEventListener("open", onOpen);
355
+ websocket.removeEventListener("error", onError);
356
+ websocket.removeEventListener("close", onClose);
357
+ signal?.removeEventListener("abort", onAbort);
358
+ };
359
+ const finish = (error) => {
360
+ cleanup();
361
+ if (error == null)
362
+ resolve();
363
+ else
364
+ reject(error);
365
+ };
366
+ const onOpen = () => finish();
367
+ const onError = () => finish(new Error("websocket open failed"));
368
+ const onClose = () => finish(new Error("websocket closed before open"));
369
+ const onAbort = () => finish(new AbortError("connect aborted"));
370
+ websocket.addEventListener("open", onOpen);
371
+ websocket.addEventListener("error", onError);
372
+ websocket.addEventListener("close", onClose);
373
+ signal?.addEventListener("abort", onAbort, { once: true });
374
+ if (timeoutMs > 0)
375
+ timer = setTimeout(() => finish(new Error("websocket open timeout")), timeoutMs);
376
+ });
377
+ }
378
+ function cleanupWaiter(waiter) {
379
+ if (waiter.onAbort != null)
380
+ waiter.signal?.removeEventListener("abort", waiter.onAbort);
381
+ }
382
+ function asError(error) {
383
+ return error instanceof Error ? error : new Error(String(error));
384
+ }