@floegence/flowersec-core 2.3.8 → 2.3.10

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 CHANGED
@@ -41,6 +41,8 @@ The controller has one scheduler and one in-flight attempt. Its states are `idle
41
41
 
42
42
  Node `SessionHandlers` accept application stream kinds whose UTF-8 encoding is 1 through 255 bytes and reserve `flowersec.rpc.v2` for Flowersec RPC. `AcceptedSession.serve(...)` half-closes successfully handled streams. A rejected handler Promise resets only that stream; the accept loop and unrelated streams continue.
43
43
 
44
+ Reliable streams apply bounded per-stream receive backpressure instead of buffering application data without limit. A slow consumer pauses carrier progress until reads release capacity; records retain carrier order, so a rekey behind backpressured DATA completes after the consumer resumes. `closeWrite()` sends the graceful FIN and keeps reads available. `reset()` and `close()` abort both directions. If a write is canceled or fails after its wire commit may have started, only that stream becomes terminal and cannot be reused.
45
+
44
46
  Source failures return a structured `terminal`, `retryable`, or `retry_after` disposition. Thrown or malformed source failures are terminal. Retry delay is deterministic exponential backoff from 250 ms, doubling to a 30-second maximum with no jitter; `retry_after` is never attempted before its specified Unix-millisecond boundary. Attempts are unlimited unless `maximumAttempts` is explicitly set.
45
47
 
46
48
  A newly established session replaces `currentSession` atomically. The controller never migrates or replays streams, RPC calls, or writes from a terminated session; callers start new application operations on the new session.
@@ -1,7 +1,8 @@
1
1
  import { createInterface } from "node:readline";
2
+ import { createPrivateKey, createPublicKey, X509Certificate } from "node:crypto";
2
3
  import { createAcceptor, createArtifactLease, createEndpointSet, createStreamMetadata, createTunnelRuntime, connect, Issuer, parseArtifact, SessionError, SessionHandlers, } from "../node/index.js";
3
4
  const RUNTIME = "node-typescript";
4
- const ORIGIN = "https://client.example";
5
+ const ORIGIN = process.env.FLOWERSEC_PARITY_ORIGIN ?? "https://client.example";
5
6
  const ECHO_RPC = 7001;
6
7
  const NOTIFY_RPC = 7002;
7
8
  const COMPLETE_RPC = 7003;
@@ -10,8 +11,10 @@ const ECHO_KIND = "parity.echo";
10
11
  const RESET_KIND = "parity.reset";
11
12
  const encoder = new TextEncoder();
12
13
  const decoder = new TextDecoder();
13
- const TEST_CERT_DER_B64 = "MIIBjzCCAUGgAwIBAgIUW8hQEpQsUJN9a6qqF2g6hsNpSm8wBQYDK2VwMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNjA3MjAxOTAxMjFaFw0zNjA3MTcxOTAxMjFaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUGAytlcAMhAAihki/Jec+1EaC6E6PsSxjMYFAazrgkNiUIlbj/+A/0o4GkMIGhMB0GA1UdDgQWBBQCuKxQmMQkAAy9KkfuD+WOmrrMbTAfBgNVHSMEGDAWgBQCuKxQmMQkAAy9KkfuD+WOmrrMbTAsBgNVHREEJTAjgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwEwBQYDK2VwA0EArZng3XitiH2E1pW/NTxQvEOBXJYpYE8coQmLV4yTjfI43CWHMG6lIrwk/so67oe6Z2R4iHGjUm3Tuy50Fl8hBw==";
14
- const TEST_KEY_DER_B64 = "MC4CAQAwBQYDK2VwBCIEICxYUWHqGoh0CBBohsaNg/NThm1n3UeWCzYuq6jS+Qi6";
14
+ // Static test-only P-256 material keeps browser parity hermetic and is never
15
+ // exposed by a production package entrypoint.
16
+ const TEST_CERT_DER_B64 = "MIIB0DCCAXWgAwIBAgIUN1vflbzlJfrU4ZKED+4S+7sCtiYwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDgxNDAyNDM0MloXDTM2MDgxMTAyNDM0MlowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEajwb7qy6VxUFH+WjP/RG8LabjthsjlqZweN2NAkwClGhWdp5XI5HEsP7p5pOpYjjD4kLsvjnBISRRf0CJIW2GKOBpDCBoTAdBgNVHQ4EFgQUe4BGlwqmqkqzEKuW7ACsFr6Dk4YwHwYDVR0jBBgwFoAUe4BGlwqmqkqzEKuW7ACsFr6Dk4YwLAYDVR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAoGCCqGSM49BAMCA0kAMEYCIQCYzxx1Zev7TI4aHaXKrj7uV4F8wkJ2kEJtogyGlMJOFwIhAOAEs50N+UKa+0B9JK6xRACX82a6bFBZCY+H9nUKikV9";
17
+ const TEST_KEY_DER_B64 = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgGw0KRCu5rtYpQtqfTVTSz97sUToj2S3UhuA/cxsDa5KhRANCAARqPBvurLpXFQUf5aM/9EbwtpuO2GyOWpnB43Y0CTAKUaFZ2nlcjkcSw/unmk6liOMPiQuy+OcEhJFF/QIkhbYY";
15
18
  class SignalQueue {
16
19
  #values = [];
17
20
  #waiters = [];
@@ -271,16 +274,41 @@ async function exchangeDatagram(session, carrier, initiator) {
271
274
  }
272
275
  function fixture() {
273
276
  const certificate = pem("CERTIFICATE", TEST_CERT_DER_B64);
277
+ const privateKey = pem("PRIVATE KEY", TEST_KEY_DER_B64);
278
+ validateTestTLSFixture(certificate, privateKey);
274
279
  return {
275
280
  tls: {
276
281
  certificate_chain_pem: certificate,
277
- private_key_pem: pem("PRIVATE KEY", TEST_KEY_DER_B64),
282
+ private_key_pem: privateKey,
278
283
  root_certificate_pem: certificate,
279
284
  root_certificate_der_base64: TEST_CERT_DER_B64,
280
285
  leaf_certificate_der_base64: TEST_CERT_DER_B64,
281
286
  },
282
287
  };
283
288
  }
289
+ function validateTestTLSFixture(certificatePEM, privateKeyPEM) {
290
+ const certificate = new X509Certificate(certificatePEM);
291
+ const validFrom = Date.parse(certificate.validFrom);
292
+ const validTo = Date.parse(certificate.validTo);
293
+ const maximumValidityMs = 11 * 366 * 24 * 60 * 60 * 1_000;
294
+ const certificatePublicKey = certificate.publicKey.export({ type: "spki", format: "der" });
295
+ const privateKeyPublicKey = createPublicKey(createPrivateKey(privateKeyPEM)).export({ type: "spki", format: "der" });
296
+ if (certificate.ca ||
297
+ certificate.publicKey.asymmetricKeyType !== "ec" ||
298
+ certificate.publicKey.asymmetricKeyDetails?.namedCurve !== "prime256v1" ||
299
+ certificate.checkHost("localhost") !== "localhost" ||
300
+ certificate.checkIP("127.0.0.1") !== "127.0.0.1" ||
301
+ certificate.checkIP("::1") !== "::1" ||
302
+ !certificate.keyUsage.includes("1.3.6.1.5.5.7.3.1") ||
303
+ !Number.isFinite(validFrom) ||
304
+ !Number.isFinite(validTo) ||
305
+ Date.now() < validFrom ||
306
+ Date.now() >= validTo ||
307
+ validTo - validFrom > maximumValidityMs ||
308
+ !certificatePublicKey.equals(privateKeyPublicKey)) {
309
+ throw new Error("browser parity TLS fixture is invalid");
310
+ }
311
+ }
284
312
  function pem(label, encoded) {
285
313
  const lines = encoded.match(/.{1,64}/g);
286
314
  if (lines === null)
@@ -334,7 +362,12 @@ async function runServer(tls, carrier) {
334
362
  const accepted = await accepting;
335
363
  state.executed.record("admission");
336
364
  const serving = accepted.serve().catch((error) => error);
337
- await exerciseServer(accepted.session, state, "direct", true, carrier);
365
+ if (process.env.FLOWERSEC_PARITY_CLIENT_PROFILE !== undefined) {
366
+ await externalServer(accepted.session, state);
367
+ }
368
+ else {
369
+ await exerciseServer(accepted.session, state, "direct", true, carrier);
370
+ }
338
371
  await serving;
339
372
  await accepted.close().catch(() => undefined);
340
373
  if (state.activeStreams.value !== 0)
@@ -481,7 +514,12 @@ async function runTunnelEndpointB(input, carrier) {
481
514
  const state = createHandlers("tunnel");
482
515
  const session = await connectArtifact(secondJSON, relay, state.handlers);
483
516
  state.executed.record("admission");
484
- await exerciseServer(session, state, "tunnel", false, carrier);
517
+ if (process.env.FLOWERSEC_PARITY_CLIENT_PROFILE !== undefined) {
518
+ await externalServer(session, state);
519
+ }
520
+ else {
521
+ await exerciseServer(session, state, "tunnel", false, carrier);
522
+ }
485
523
  await session.close().catch(() => undefined);
486
524
  state.executed.record("close");
487
525
  if (state.activeStreams.value !== 0)
@@ -489,6 +527,10 @@ async function runTunnelEndpointB(input, carrier) {
489
527
  state.executed.record("cleanup");
490
528
  writeJSON(result("endpoint-b-result", "tunnel", state.executed.snapshot(), carrier));
491
529
  }
530
+ async function externalServer(session, state) {
531
+ await session.waitTermination();
532
+ state.executed.record("close");
533
+ }
492
534
  async function runTunnelEndpointA(input, carrier) {
493
535
  const envelope = await input.next();
494
536
  validateTunnelDimensions(envelope.topology, envelope.endpoint_b.relay, "endpoint_a", carrier);
@@ -45,6 +45,7 @@ type NativeRawQuicStreamBinding = Readonly<{
45
45
  closeWrite(): Promise<void>;
46
46
  stopSending(): Promise<void>;
47
47
  reset(): Promise<void>;
48
+ cancelPending?(): void;
48
49
  abort(): void;
49
50
  }>;
50
51
  type NativeRawQuicSessionBinding = Readonly<{
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { CarrierError, } from "../v2/carrier.js";
3
3
  export const NATIVE_TRANSPORT_CONTRACT_VERSION = 1;
4
4
  const NATIVE_PACKAGE = "@floegence/flowersec-node-native";
5
+ const SERVER_PARITY_NATIVE_ADDON = "FLOWERSEC_SERVER_PARITY_NATIVE_ADDON";
5
6
  export class NativeTransportUnavailableError extends Error {
6
7
  code = "native_transport_unavailable";
7
8
  constructor() {
@@ -12,7 +13,7 @@ export class NativeTransportUnavailableError extends Error {
12
13
  export function loadNativeTransportAddon(requireFunction = createRequire(import.meta.url)) {
13
14
  let candidate;
14
15
  try {
15
- candidate = requireFunction(NATIVE_PACKAGE);
16
+ candidate = requireFunction(nativePackageSpecifier());
16
17
  }
17
18
  catch {
18
19
  throw new NativeTransportUnavailableError();
@@ -22,6 +23,14 @@ export function loadNativeTransportAddon(requireFunction = createRequire(import.
22
23
  }
23
24
  return candidate;
24
25
  }
26
+ function nativePackageSpecifier() {
27
+ if (process.env.FLOWERSEC_SERVER_PARITY_PEER === "1") {
28
+ const override = process.env[SERVER_PARITY_NATIVE_ADDON];
29
+ if (override !== undefined && override !== "")
30
+ return override;
31
+ }
32
+ return NATIVE_PACKAGE;
33
+ }
25
34
  export function tryLoadNativeTransportAddon(requireFunction = createRequire(import.meta.url)) {
26
35
  try {
27
36
  return loadNativeTransportAddon(requireFunction);
@@ -117,21 +126,54 @@ function wrapSession(native) {
117
126
  }
118
127
  function wrapStream(native) {
119
128
  return Object.freeze({
120
- read: async () => await native.read(),
121
- write: async (data) => await native.write(data),
122
- closeWrite: async () => await native.closeWrite(),
123
- stopSending: async () => await native.stopSending(),
124
- reset: async () => await native.reset(),
129
+ read: async () => await nativeStreamCall(native.read()),
130
+ write: async (data) => await nativeStreamCall(native.write(data)),
131
+ closeWrite: async () => await nativeStreamCall(native.closeWrite()),
132
+ stopSending: async () => await nativeStreamCall(native.stopSending()),
133
+ reset: async () => await nativeStreamCall(native.reset()),
134
+ cancelPending: () => native.cancelPending?.(),
125
135
  abort: () => native.abort(),
126
136
  });
127
137
  }
138
+ async function nativeStreamCall(operation) {
139
+ try {
140
+ return await operation;
141
+ }
142
+ catch (error) {
143
+ const reason = error instanceof Error ? error.message : "";
144
+ switch (reason) {
145
+ case "reset": throw new CarrierError("reset", "raw QUIC stream reset", error);
146
+ case "canceled": throw new CarrierError("aborted", "raw QUIC stream operation canceled", error);
147
+ case "closed": throw new CarrierError("closed", "raw QUIC stream closed", error);
148
+ case "stream_failed": throw new CarrierError("closed", "raw QUIC stream operation closed", error);
149
+ default: throw error;
150
+ }
151
+ }
152
+ }
153
+ function nativeOperationError(error) {
154
+ const reason = error instanceof Error ? error.message : "";
155
+ switch (reason) {
156
+ case "canceled": return new CarrierError("aborted", "native transport operation canceled", error);
157
+ case "closed":
158
+ case "listener_closed":
159
+ case "stream_failed":
160
+ return new CarrierError("closed", "native transport operation closed", error);
161
+ default: return error;
162
+ }
163
+ }
128
164
  async function settle(operation, signal) {
129
165
  if (signal?.aborted === true) {
130
166
  operation.cancel();
131
167
  throw new CarrierError("aborted", "native transport operation aborted");
132
168
  }
133
- if (signal === undefined)
134
- return await operation.result();
169
+ if (signal === undefined) {
170
+ try {
171
+ return await operation.result();
172
+ }
173
+ catch (error) {
174
+ throw nativeOperationError(error);
175
+ }
176
+ }
135
177
  return await new Promise((resolve, reject) => {
136
178
  let settled = false;
137
179
  const cleanup = () => signal.removeEventListener("abort", abort);
@@ -155,7 +197,7 @@ async function settle(operation, signal) {
155
197
  return;
156
198
  settled = true;
157
199
  cleanup();
158
- reject(error);
200
+ reject(nativeOperationError(error));
159
201
  });
160
202
  });
161
203
  }
@@ -301,9 +301,11 @@ async function bridgeStreams(source, target, slots, signal, start) {
301
301
  }
302
302
  catch (error) {
303
303
  release();
304
- incoming.abort(asError(error));
305
- if (signal.aborted)
304
+ if (signal.aborted) {
305
+ incoming.abort(asError(error));
306
306
  throw error;
307
+ }
308
+ await incoming.reset().catch(() => undefined);
307
309
  continue;
308
310
  }
309
311
  start(spliceStreams(incoming, outgoing, signal, false).finally(release), false);
@@ -321,8 +323,13 @@ async function spliceStreams(left, right, signal, closePair) {
321
323
  throw new Error("Flowersec tunnel control stream closed");
322
324
  }
323
325
  catch (error) {
324
- left.abort(asError(error));
325
- right.abort(asError(error));
326
+ if (closePair || signal.aborted) {
327
+ left.abort(asError(error));
328
+ right.abort(asError(error));
329
+ }
330
+ else {
331
+ await Promise.allSettled([left.reset(), right.reset()]);
332
+ }
326
333
  throw error;
327
334
  }
328
335
  finally {
@@ -352,8 +352,13 @@ export function createProxyRuntime(options) {
352
352
  }
353
353
  const headersOut = filterHeaders(response.headers ?? [], BASE_RESPONSE_HEADERS, responseExtra);
354
354
  port.postMessage({ type: "flowersec-proxy:response_meta", status: response.status, headers: headersOut });
355
- for await (const chunk of readChunks(reader, maxChunkBytes, maxBodyBytes)) {
355
+ const chunks = readChunks(reader, maxChunkBytes, maxBodyBytes)[Symbol.asyncIterator]();
356
+ for (;;) {
356
357
  await waitForCredit();
358
+ const next = await chunks.next();
359
+ if (next.done)
360
+ break;
361
+ const chunk = next.value;
357
362
  const data = chunk.slice().buffer;
358
363
  port.postMessage({ type: "flowersec-proxy:response_chunk", data }, [data]);
359
364
  }
@@ -121,8 +121,11 @@ function serviceWorkerMain(config) {
121
121
  const target = config.windowTarget === "request_client"
122
122
  ? source
123
123
  : runtimeClientId === "" ? null : await worker.clients.get(runtimeClientId);
124
- if (target === null)
124
+ if (target === null) {
125
+ if (config.windowTarget === "registered_runtime")
126
+ runtimeClientId = "";
125
127
  return new Response("proxy runtime unavailable", { status: 503 });
128
+ }
126
129
  let body;
127
130
  if (request.method !== "GET" && request.method !== "HEAD") {
128
131
  const requestBody = await request.clone().arrayBuffer();
@@ -188,17 +191,25 @@ function serviceWorkerMain(config) {
188
191
  };
189
192
  channel.port1.onmessageerror = () => finishError(502, "proxy request failed");
190
193
  const headers = Array.from(request.headers.entries()).map(([name, value]) => ({ name, value }));
191
- target.postMessage({
192
- type: config.windowClientMessageType,
193
- req: {
194
- id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
195
- method: request.method,
196
- path,
197
- headers,
198
- response_flow_control: "chunk_credit_v2",
199
- ...(body === undefined ? {} : { body }),
200
- },
201
- }, [channel.port2, ...(body === undefined ? [] : [body])]);
194
+ try {
195
+ target.postMessage({
196
+ type: config.windowClientMessageType,
197
+ req: {
198
+ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
199
+ method: request.method,
200
+ path,
201
+ headers,
202
+ response_flow_control: "chunk_credit_v2",
203
+ ...(body === undefined ? {} : { body }),
204
+ },
205
+ }, [channel.port2, ...(body === undefined ? [] : [body])]);
206
+ }
207
+ catch {
208
+ if (config.windowTarget === "registered_runtime")
209
+ runtimeClientId = "";
210
+ channel.port1.close();
211
+ resolve(new Response("proxy runtime unavailable", { status: 503 }));
212
+ }
202
213
  });
203
214
  const injection = config.injectHTML;
204
215
  if (injection === null || pathMatches(url.pathname, injection.excludePathPrefixes ?? []))
@@ -490,9 +490,9 @@ async function raceAbort(promise, signal) {
490
490
  if (signal === undefined)
491
491
  return await promise;
492
492
  if (signal.aborted)
493
- throw signal.reason;
493
+ throw abortedCarrierError(signal.reason);
494
494
  return await new Promise((resolve, reject) => {
495
- const abort = () => reject(signal.reason);
495
+ const abort = () => reject(abortedCarrierError(signal.reason));
496
496
  signal.addEventListener("abort", abort, { once: true });
497
497
  void promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
498
498
  });
@@ -52,6 +52,8 @@ export type NativeCarrierStreamV2 = Readonly<{
52
52
  closeWrite(): Promise<void>;
53
53
  stopSending(): Promise<void>;
54
54
  reset(): Promise<void>;
55
+ /** Cancels pending local primitives without initiating peer-visible teardown. */
56
+ cancelPending?(): void;
55
57
  /** See {@link CarrierStreamV2.abort}. */
56
58
  abort(error?: Error): void;
57
59
  }>;
@@ -353,11 +353,11 @@ class NativeCarrierStreamAdapter {
353
353
  this.native = native;
354
354
  }
355
355
  async read(options = {}) {
356
- return await abortable(this.native.read(), options.signal, () => this.native.abort());
356
+ return await abortable(this.native.read(), options.signal, () => this.cancelPending());
357
357
  }
358
358
  async write(data, options = {}) {
359
359
  throwIfAborted(options.signal);
360
- return await abortable(this.native.write(data), options.signal, () => this.native.abort());
360
+ return await abortable(this.native.write(data), options.signal, () => this.cancelPending());
361
361
  }
362
362
  async closeWrite() {
363
363
  await this.native.closeWrite();
@@ -371,6 +371,12 @@ class NativeCarrierStreamAdapter {
371
371
  abort(error) {
372
372
  this.native.abort(error);
373
373
  }
374
+ cancelPending() {
375
+ if (this.native.cancelPending !== undefined)
376
+ this.native.cancelPending();
377
+ else
378
+ this.native.abort();
379
+ }
374
380
  }
375
381
  async function abortable(promise, signal, onAbort) {
376
382
  if (signal === undefined)
@@ -104,12 +104,15 @@ export declare class SessionV2 implements SessionV2Contract {
104
104
  private sessionCloseCommitted;
105
105
  private readonly peerSessionClose;
106
106
  private readonly streams;
107
+ private readonly releasedStreams;
108
+ private readonly releasedStreamCleanup;
107
109
  private readonly peerLedger;
108
110
  private readonly outboundLedger;
109
111
  private readonly incoming;
110
112
  private readonly outboundPermits;
111
113
  private readonly inboundPermits;
112
114
  private readonly pings;
115
+ private readonly backgroundTasks;
113
116
  private nextPing;
114
117
  private readonly rpcActivation;
115
118
  private rpcStreamPromise;
@@ -153,6 +156,7 @@ export declare class SessionV2 implements SessionV2Contract {
153
156
  }>>;
154
157
  localReset(stream: EncryptedStreamV2, error: Error): Promise<void>;
155
158
  releaseStream(stream: EncryptedStreamV2): void;
159
+ private confirmControlDelivery;
156
160
  private openLogicalStream;
157
161
  private ensureRPCStream;
158
162
  private acceptCarrierLoop;
@@ -184,6 +188,7 @@ export declare class SessionV2 implements SessionV2Contract {
184
188
  private validGoAwayBoundary;
185
189
  private sendGoAway;
186
190
  private receiveGoAway;
191
+ private trackBackground;
187
192
  private localOpeningAllowedAfterGoAway;
188
193
  private acceptsPeerStreamAfterGoAway;
189
194
  private cleanupEpochRoots;
@@ -224,6 +229,7 @@ declare class EncryptedStreamV2 implements ByteStreamV2 {
224
229
  private remoteFIN;
225
230
  private pumpStarted;
226
231
  private permitReleased;
232
+ private closeWriteTask;
227
233
  private pendingSendRekey;
228
234
  private lastSendRekeyACK;
229
235
  private receiveRekey;
@@ -235,6 +241,7 @@ declare class EncryptedStreamV2 implements ByteStreamV2 {
235
241
  close(): Promise<void>;
236
242
  send(type: InnerTypeV2, payload: Uint8Array, signal?: AbortSignal): Promise<void>;
237
243
  canRekeySend(): boolean;
244
+ hasRemoteFIN(): boolean;
238
245
  startSendRekey(transition: bigint, epoch: number): Readonly<{
239
246
  armed: Deferred<void>;
240
247
  done: Deferred<void>;
@@ -245,6 +252,7 @@ declare class EncryptedStreamV2 implements ByteStreamV2 {
245
252
  markOpen(): void;
246
253
  markTerminal(error: Error): boolean;
247
254
  peerReset(error: Error): void;
255
+ carrierReset(): void;
248
256
  abort(error?: Error): void;
249
257
  releasePermit(): void;
250
258
  private pump;
@@ -274,12 +282,15 @@ declare class ByteQueue {
274
282
  private closed;
275
283
  private error;
276
284
  private readonly waiters;
285
+ private readonly capacityWaiters;
277
286
  constructor(limit: number);
278
287
  push(chunk: Uint8Array): void;
279
288
  close(): void;
280
289
  fail(error: Error): void;
290
+ waitForCapacity(required: number): Promise<void>;
281
291
  read(signal?: AbortSignal): Promise<Uint8Array | null>;
282
292
  private wake;
293
+ private wakeCapacity;
283
294
  }
284
295
  type Deferred<T> = Readonly<{
285
296
  promise: Promise<T>;
@@ -12,6 +12,7 @@ const MAX_BUFFERED_STREAM_BYTES = 4 * 1024 * 1024;
12
12
  const RESERVED_RPC_KIND = "flowersec.rpc.v2";
13
13
  const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
14
14
  const DEFAULT_CLOSE_TIMEOUT_MS = 2_000;
15
+ const DEFAULT_CONTROL_CONFIRM_TIMEOUT_MS = 2_000;
15
16
  export class SessionV2Error extends Error {
16
17
  code;
17
18
  constructor(code, message) {
@@ -108,12 +109,15 @@ export class SessionV2 {
108
109
  sessionCloseCommitted = false;
109
110
  peerSessionClose = deferred();
110
111
  streams = new Map();
112
+ releasedStreams = new Map();
113
+ releasedStreamCleanup = releasedStreamCleanup(this.releasedStreams);
111
114
  peerLedger;
112
115
  outboundLedger;
113
116
  incoming = new AsyncQueue();
114
117
  outboundPermits;
115
118
  inboundPermits;
116
119
  pings = new Map();
120
+ backgroundTasks = new Set();
117
121
  nextPing = 1n;
118
122
  rpcActivation = deferred();
119
123
  rpcStreamPromise;
@@ -305,23 +309,51 @@ export class SessionV2 {
305
309
  async localReset(stream, error) {
306
310
  if (!stream.markTerminal(error))
307
311
  return;
312
+ this.releaseStream(stream);
313
+ let controlConfirmed = false;
308
314
  try {
309
315
  await this.sendControl(InnerTypeV2.StreamReset, idReason(stream.id, 6));
310
316
  this.commitLocalReset(stream.id);
317
+ if (stream.hasRemoteFIN())
318
+ controlConfirmed = await this.confirmControlDelivery();
311
319
  }
312
320
  catch (cause) {
313
321
  this.fail(asError(cause));
314
322
  }
315
- await stream.carrier.reset().catch(() => undefined);
316
- this.releaseStream(stream);
323
+ if (controlConfirmed) {
324
+ await stream.carrier.closeWrite().catch(async () => await stream.carrier.reset().catch(() => undefined));
325
+ }
326
+ else {
327
+ await stream.carrier.reset().catch(() => undefined);
328
+ }
317
329
  }
318
330
  releaseStream(stream) {
331
+ if (stream.terminalError !== undefined) {
332
+ this.releasedStreams.delete(stream.id);
333
+ this.releasedStreamCleanup.unregister(stream);
334
+ }
319
335
  if (this.streams.get(stream.id) !== stream)
320
336
  return;
321
337
  this.streams.delete(stream.id);
338
+ if (stream.terminalError === undefined) {
339
+ this.releasedStreams.set(stream.id, new WeakRef(stream));
340
+ this.releasedStreamCleanup.register(stream, stream.id, stream);
341
+ }
322
342
  stream.releasePermit();
323
343
  this.cleanupEpochRoots();
324
344
  }
345
+ async confirmControlDelivery() {
346
+ const nonce = this.nextPing++;
347
+ const pending = deferred();
348
+ this.pings.set(nonce, pending);
349
+ try {
350
+ await this.sendControl(InnerTypeV2.Ping, u64(nonce));
351
+ return await resolveWithin(pending.promise, DEFAULT_CONTROL_CONFIRM_TIMEOUT_MS);
352
+ }
353
+ finally {
354
+ this.pings.delete(nonce);
355
+ }
356
+ }
325
357
  async openLogicalStream(kind, options, internal) {
326
358
  this.assertOpen();
327
359
  throwIfAborted(options.signal);
@@ -599,7 +631,21 @@ export class SessionV2 {
599
631
  const { id, reason } = parseIDReason(record.payload);
600
632
  if (id === 0n || reason === 0)
601
633
  throw protocolError("invalid STREAM_RESET");
602
- this.streams.get(id)?.peerReset(new SessionV2Error("stream_reset", "logical stream reset by peer"));
634
+ const stream = this.streams.get(id);
635
+ if (stream !== undefined) {
636
+ stream.peerReset(new SessionV2Error("stream_reset", "logical stream reset by peer"));
637
+ }
638
+ else {
639
+ const released = this.releasedStreams.get(id);
640
+ if (released !== undefined) {
641
+ this.releasedStreams.delete(id);
642
+ const releasedStream = released.deref();
643
+ if (releasedStream !== undefined) {
644
+ this.releasedStreamCleanup.unregister(releasedStream);
645
+ releasedStream.markTerminal(new SessionV2Error("stream_reset", "logical stream reset by peer"));
646
+ }
647
+ }
648
+ }
603
649
  if (this.isLocalLogicalID(id)) {
604
650
  this.outboundLedger.peerReset(id);
605
651
  this.notifyOutboundFrontierChanged();
@@ -907,9 +953,16 @@ export class SessionV2 {
907
953
  this.receivedGoAwayLastAccepted = lastAccepted;
908
954
  this.receivedGoAwayReason = reason;
909
955
  const excluded = [...this.streams.values()].filter((stream) => this.isLocalLogicalID(stream.id) && stream.id > lastAccepted);
910
- await Promise.allSettled(excluded.map(async (stream) => {
911
- await this.localReset(stream, new SessionV2Error("going_away", "logical stream exceeds peer GOAWAY boundary"));
912
- }));
956
+ for (const stream of excluded) {
957
+ this.trackBackground(this.localReset(stream, new SessionV2Error("going_away", "logical stream exceeds peer GOAWAY boundary")));
958
+ }
959
+ }
960
+ trackBackground(task) {
961
+ this.backgroundTasks.add(task);
962
+ void task.catch((error) => {
963
+ if (this.lifecycle !== "closed")
964
+ this.fail(asError(error));
965
+ }).finally(() => this.backgroundTasks.delete(task));
913
966
  }
914
967
  localOpeningAllowedAfterGoAway(id) {
915
968
  return !this.receivedGoAway || id <= this.receivedGoAwayLastAccepted;
@@ -995,6 +1048,12 @@ export class SessionV2 {
995
1048
  this.rpc.close();
996
1049
  for (const stream of [...this.streams.values()])
997
1050
  stream.peerReset(error);
1051
+ for (const released of this.releasedStreams.values()) {
1052
+ const stream = released.deref();
1053
+ if (stream !== undefined)
1054
+ this.releasedStreamCleanup.unregister(stream);
1055
+ }
1056
+ this.releasedStreams.clear();
998
1057
  this.wipeAllRoots();
999
1058
  this.h3.fill(0);
1000
1059
  if (abortCarrier && !normalPeerCarrierClose)
@@ -1032,6 +1091,7 @@ class EncryptedStreamV2 {
1032
1091
  remoteFIN = false;
1033
1092
  pumpStarted = false;
1034
1093
  permitReleased = false;
1094
+ closeWriteTask;
1035
1095
  pendingSendRekey;
1036
1096
  lastSendRekeyACK;
1037
1097
  receiveRekey;
@@ -1064,9 +1124,16 @@ class EncryptedStreamV2 {
1064
1124
  throw this.terminalError;
1065
1125
  const copy = payload.slice();
1066
1126
  await this.enqueueSend(async () => {
1067
- for (let offset = 0; offset < copy.length; offset += MAX_DATA_BYTES) {
1068
- throwIfAborted(options.signal);
1069
- await this.session.sendStreamRecord(this, InnerTypeV2.Data, copy.subarray(offset, Math.min(copy.length, offset + MAX_DATA_BYTES)));
1127
+ try {
1128
+ for (let offset = 0; offset < copy.length; offset += MAX_DATA_BYTES) {
1129
+ throwIfAborted(options.signal);
1130
+ await this.session.sendStreamRecord(this, InnerTypeV2.Data, copy.subarray(offset, Math.min(copy.length, offset + MAX_DATA_BYTES)), options.signal);
1131
+ }
1132
+ }
1133
+ catch (error) {
1134
+ const normalized = asError(error);
1135
+ void this.session.localReset(this, normalized);
1136
+ throw normalized;
1070
1137
  }
1071
1138
  });
1072
1139
  return copy.length;
@@ -1074,16 +1141,26 @@ class EncryptedStreamV2 {
1074
1141
  async closeWrite() {
1075
1142
  await this.opened.promise;
1076
1143
  await this.waitSendRekey();
1077
- if (this.localFIN || this.terminalError !== undefined)
1144
+ if (this.localFIN)
1078
1145
  return;
1079
- await this.enqueueSend(async () => {
1080
- if (this.localFIN)
1081
- return;
1082
- this.localFIN = true;
1083
- await this.session.sendStreamRecord(this, InnerTypeV2.FIN, new Uint8Array());
1084
- await this.carrier.closeWrite();
1085
- this.releaseIfClean();
1146
+ if (this.terminalError !== undefined)
1147
+ throw this.terminalError;
1148
+ this.closeWriteTask ??= this.enqueueSend(async () => {
1149
+ try {
1150
+ if (this.localFIN)
1151
+ return;
1152
+ await this.session.sendStreamRecord(this, InnerTypeV2.FIN, new Uint8Array());
1153
+ await this.carrier.closeWrite();
1154
+ this.localFIN = true;
1155
+ this.releaseIfClean();
1156
+ }
1157
+ catch (error) {
1158
+ const normalized = asError(error);
1159
+ void this.session.localReset(this, normalized);
1160
+ throw normalized;
1161
+ }
1086
1162
  });
1163
+ await this.closeWriteTask;
1087
1164
  }
1088
1165
  async reset() {
1089
1166
  await this.session.localReset(this, new SessionV2Error("stream_reset", "logical stream reset"));
@@ -1097,6 +1174,9 @@ class EncryptedStreamV2 {
1097
1174
  canRekeySend() {
1098
1175
  return this.terminalError === undefined && this.opened.settled && !this.localFIN;
1099
1176
  }
1177
+ hasRemoteFIN() {
1178
+ return this.remoteFIN;
1179
+ }
1100
1180
  startSendRekey(transition, epoch) {
1101
1181
  if (this.pendingSendRekey !== undefined)
1102
1182
  throw protocolError("overlapping stream rekey");
@@ -1157,6 +1237,11 @@ class EncryptedStreamV2 {
1157
1237
  this.carrier.abort(error);
1158
1238
  this.session.releaseStream(this);
1159
1239
  }
1240
+ carrierReset() {
1241
+ if (!this.markTerminal(new SessionV2Error("stream_reset", "logical stream reset by peer")))
1242
+ return;
1243
+ this.session.releaseStream(this);
1244
+ }
1160
1245
  abort(error = new SessionV2Error("closed", "logical stream aborted")) {
1161
1246
  this.peerReset(error);
1162
1247
  }
@@ -1196,6 +1281,7 @@ class EncryptedStreamV2 {
1196
1281
  case InnerTypeV2.Data:
1197
1282
  if (this.remoteFIN)
1198
1283
  throw protocolError("DATA after FIN");
1284
+ await this.data.waitForCapacity(record.payload.length);
1199
1285
  this.data.push(record.payload);
1200
1286
  break;
1201
1287
  case InnerTypeV2.FIN:
@@ -1218,6 +1304,10 @@ class EncryptedStreamV2 {
1218
1304
  }
1219
1305
  catch (error) {
1220
1306
  const normalized = asError(error);
1307
+ if (normalized instanceof CarrierError && normalized.code === "reset") {
1308
+ this.carrierReset();
1309
+ return;
1310
+ }
1221
1311
  if (this.remoteFIN && /closed|EOF/i.test(normalized.message)) {
1222
1312
  this.releaseIfClean();
1223
1313
  return;
@@ -1461,6 +1551,7 @@ class ByteQueue {
1461
1551
  closed = false;
1462
1552
  error;
1463
1553
  waiters = new Set();
1554
+ capacityWaiters = new Set();
1464
1555
  constructor(limit) {
1465
1556
  this.limit = limit;
1466
1557
  }
@@ -1485,6 +1576,26 @@ class ByteQueue {
1485
1576
  this.head = 0;
1486
1577
  this.bytes = 0;
1487
1578
  this.wake(error);
1579
+ this.wakeCapacity(error);
1580
+ }
1581
+ async waitForCapacity(required) {
1582
+ if (!Number.isInteger(required) || required < 0 || required > this.limit) {
1583
+ throw new RangeError("invalid stream receive capacity request");
1584
+ }
1585
+ while (this.bytes + required > this.limit) {
1586
+ if (this.error !== undefined)
1587
+ throw this.error;
1588
+ if (this.closed)
1589
+ return;
1590
+ const waiter = deferred();
1591
+ this.capacityWaiters.add(waiter);
1592
+ try {
1593
+ await waiter.promise;
1594
+ }
1595
+ finally {
1596
+ this.capacityWaiters.delete(waiter);
1597
+ }
1598
+ }
1488
1599
  }
1489
1600
  async read(signal) {
1490
1601
  while (true) {
@@ -1493,6 +1604,7 @@ class ByteQueue {
1493
1604
  if (this.head < this.chunks.length) {
1494
1605
  const chunk = this.chunks[this.head++];
1495
1606
  this.bytes -= chunk.length;
1607
+ this.wakeCapacity();
1496
1608
  if (this.head > 1_024 && this.head * 2 > this.chunks.length) {
1497
1609
  this.chunks.splice(0, this.head);
1498
1610
  this.head = 0;
@@ -1516,6 +1628,11 @@ class ByteQueue {
1516
1628
  error === undefined ? waiter.resolve() : waiter.reject(error);
1517
1629
  this.waiters.clear();
1518
1630
  }
1631
+ wakeCapacity(error) {
1632
+ for (const waiter of [...this.capacityWaiters])
1633
+ error === undefined ? waiter.resolve() : waiter.reject(error);
1634
+ this.capacityWaiters.clear();
1635
+ }
1519
1636
  }
1520
1637
  class AsyncSemaphore {
1521
1638
  available;
@@ -1742,6 +1859,12 @@ async function settleWithin(promise, timeoutMs) {
1742
1859
  void promise.then(() => { clearTimeout(timer); resolve(true); }, () => { clearTimeout(timer); resolve(true); });
1743
1860
  });
1744
1861
  }
1862
+ async function resolveWithin(promise, timeoutMs) {
1863
+ return await new Promise((resolve) => {
1864
+ const timer = setTimeout(() => resolve(false), timeoutMs);
1865
+ void promise.then(() => { clearTimeout(timer); resolve(true); }, () => { clearTimeout(timer); resolve(false); });
1866
+ });
1867
+ }
1745
1868
  function sessionIdleTimeoutMs(config) {
1746
1869
  const timeoutMs = config.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
1747
1870
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0) {
@@ -1845,6 +1968,13 @@ function abortedError() {
1845
1968
  function abortReason(signal) {
1846
1969
  return signal.reason instanceof Error ? signal.reason : abortedError();
1847
1970
  }
1971
+ function releasedStreamCleanup(streams) {
1972
+ return new FinalizationRegistry((id) => {
1973
+ const stream = streams.get(id);
1974
+ if (stream !== undefined && stream.deref() === undefined)
1975
+ streams.delete(id);
1976
+ });
1977
+ }
1848
1978
  function createSessionDeadline(config, phase) {
1849
1979
  const deadlines = config.deadlines;
1850
1980
  const timeoutMs = phase === "establish"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "2.3.8",
3
+ "version": "2.3.10",
4
4
  "description": "Flowersec core TypeScript library for carrier-neutral encrypted sessions and multiplexed streams.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -77,7 +77,7 @@
77
77
  "ws": "^8.21.2"
78
78
  },
79
79
  "optionalDependencies": {
80
- "@floegence/flowersec-node-native": "2.3.8"
80
+ "@floegence/flowersec-node-native": "2.3.10"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@playwright/test": "1.62.1",
@@ -95,5 +95,5 @@
95
95
  "vite": "^8.2.1",
96
96
  "vitest": "4.1.10"
97
97
  },
98
- "flowersecSourceCommit": "42ecb756ca3b1d0201628534f0304f9d95b09915"
98
+ "flowersecSourceCommit": "67e5ff521e77982e4f57e8ba5eb858df3e48c886"
99
99
  }
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:e995e70c-2186-53ce-8d53-420ef784523c",
4
+ "serialNumber": "urn:uuid:2ff806a3-4e32-5ae7-867b-3dc94826ed55",
5
5
  "version": 1,
6
6
  "metadata": {
7
7
  "component": {
8
8
  "type": "library",
9
9
  "name": "@floegence/flowersec-core",
10
- "version": "2.3.8",
11
- "purl": "pkg:npm/%40floegence/flowersec-core@2.3.8",
12
- "bom-ref": "pkg:npm/%40floegence/flowersec-core@2.3.8"
10
+ "version": "2.3.10",
11
+ "purl": "pkg:npm/%40floegence/flowersec-core@2.3.10",
12
+ "bom-ref": "pkg:npm/%40floegence/flowersec-core@2.3.10"
13
13
  },
14
14
  "properties": [
15
15
  {
16
16
  "name": "flowersec:source-inventory-sha256",
17
- "value": "61acdda38c1b589f90670adc84ce2cfd838f91aaae3f7881bfc17397951c98ca"
17
+ "value": "36475d4f06355a764285462713f086777546ed34fe2ea5911961d9bb9a3037f1"
18
18
  }
19
19
  ]
20
20
  },
@@ -190,7 +190,7 @@
190
190
  ],
191
191
  "dependencies": [
192
192
  {
193
- "ref": "pkg:npm/%40floegence/flowersec-core@2.3.8",
193
+ "ref": "pkg:npm/%40floegence/flowersec-core@2.3.10",
194
194
  "dependsOn": [
195
195
  "pkg:npm/%40noble/ciphers@2.3.0",
196
196
  "pkg:npm/%40noble/curves@2.3.0",
package/sbom/spdx.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
5
  "name": "flowersec-ts",
6
- "documentNamespace": "https://github.com/floegence/flowersec/sbom/flowersec-ts/61acdda38c1b589f90670adc84ce2cfd838f91aaae3f7881bfc17397951c98ca",
6
+ "documentNamespace": "https://github.com/floegence/flowersec/sbom/flowersec-ts/36475d4f06355a764285462713f086777546ed34fe2ea5911961d9bb9a3037f1",
7
7
  "creationInfo": {
8
8
  "created": "1970-01-01T00:00:00Z",
9
9
  "creators": [
@@ -13,19 +13,19 @@
13
13
  "packages": [
14
14
  {
15
15
  "name": "@floegence/flowersec-core",
16
- "SPDXID": "SPDXRef-Package-4908e4f26e4e95fbc182",
17
- "versionInfo": "2.3.8",
16
+ "SPDXID": "SPDXRef-Package-bd5ff7d2005890830d32",
17
+ "versionInfo": "2.3.10",
18
18
  "downloadLocation": "NOASSERTION",
19
19
  "filesAnalyzed": false,
20
20
  "licenseConcluded": "NOASSERTION",
21
21
  "licenseDeclared": "NOASSERTION",
22
22
  "copyrightText": "NOASSERTION",
23
- "comment": "Flowersec source inventory SHA-256: 61acdda38c1b589f90670adc84ce2cfd838f91aaae3f7881bfc17397951c98ca",
23
+ "comment": "Flowersec source inventory SHA-256: 36475d4f06355a764285462713f086777546ed34fe2ea5911961d9bb9a3037f1",
24
24
  "externalRefs": [
25
25
  {
26
26
  "referenceCategory": "PACKAGE-MANAGER",
27
27
  "referenceType": "purl",
28
- "referenceLocator": "pkg:npm/%40floegence/flowersec-core@2.3.8"
28
+ "referenceLocator": "pkg:npm/%40floegence/flowersec-core@2.3.10"
29
29
  }
30
30
  ]
31
31
  },
@@ -136,30 +136,30 @@
136
136
  {
137
137
  "spdxElementId": "SPDXRef-DOCUMENT",
138
138
  "relationshipType": "DESCRIBES",
139
- "relatedSpdxElement": "SPDXRef-Package-4908e4f26e4e95fbc182"
139
+ "relatedSpdxElement": "SPDXRef-Package-bd5ff7d2005890830d32"
140
140
  },
141
141
  {
142
- "spdxElementId": "SPDXRef-Package-4908e4f26e4e95fbc182",
142
+ "spdxElementId": "SPDXRef-Package-bd5ff7d2005890830d32",
143
143
  "relationshipType": "DEPENDS_ON",
144
144
  "relatedSpdxElement": "SPDXRef-Package-5ce913a03239b02770fb"
145
145
  },
146
146
  {
147
- "spdxElementId": "SPDXRef-Package-4908e4f26e4e95fbc182",
147
+ "spdxElementId": "SPDXRef-Package-bd5ff7d2005890830d32",
148
148
  "relationshipType": "DEPENDS_ON",
149
149
  "relatedSpdxElement": "SPDXRef-Package-01009adf60db02c13634"
150
150
  },
151
151
  {
152
- "spdxElementId": "SPDXRef-Package-4908e4f26e4e95fbc182",
152
+ "spdxElementId": "SPDXRef-Package-bd5ff7d2005890830d32",
153
153
  "relationshipType": "DEPENDS_ON",
154
154
  "relatedSpdxElement": "SPDXRef-Package-8815b117c8a1d5f7eeb0"
155
155
  },
156
156
  {
157
- "spdxElementId": "SPDXRef-Package-4908e4f26e4e95fbc182",
157
+ "spdxElementId": "SPDXRef-Package-bd5ff7d2005890830d32",
158
158
  "relationshipType": "DEPENDS_ON",
159
159
  "relatedSpdxElement": "SPDXRef-Package-f8b45a289df643042b94"
160
160
  },
161
161
  {
162
- "spdxElementId": "SPDXRef-Package-4908e4f26e4e95fbc182",
162
+ "spdxElementId": "SPDXRef-Package-bd5ff7d2005890830d32",
163
163
  "relationshipType": "DEPENDS_ON",
164
164
  "relatedSpdxElement": "SPDXRef-Package-b779412f685822663496"
165
165
  },