@genation/bridge-peer 0.1.1 → 0.1.3

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.
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |spec|
2
2
  spec.name = "GenationBridgePeer"
3
- spec.version = "0.1.1"
3
+ spec.version = "0.1.3"
4
4
  spec.summary = "Native secure identity and shared Rust crypto host for Bridge peers"
5
5
  spec.license = { :type => "Apache-2.0" }
6
6
  spec.author = "Genation"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genation/bridge-peer",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Node.js, Electron, and React Native peer SDK for Genation Bridge",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -46,7 +46,7 @@
46
46
  }
47
47
  },
48
48
  "dependencies": {
49
- "@genation/bridge-contracts": "0.1.1",
49
+ "@genation/bridge-contracts": "0.1.3",
50
50
  "jose": "^6.2.10",
51
51
  "zod": "^4.3.6"
52
52
  },
@@ -14,6 +14,7 @@ import { loadBridgeCryptoCore } from "./mod.js";
14
14
  return Object.freeze({
15
15
  beginHandshake: ()=>session.begin_handshake(),
16
16
  finishHandshake: (message)=>session.finish_handshake(message),
17
+ finishHandshakeWithPayload: (message)=>session.finish_handshake_with_payload(message),
17
18
  encrypt: (aad, plaintext)=>session.encrypt(aad, plaintext),
18
19
  decrypt: (aad, ciphertext)=>session.decrypt(aad, ciphertext),
19
20
  close: ()=>{
@@ -29,6 +30,7 @@ import { loadBridgeCryptoCore } from "./mod.js";
29
30
  let closed = false;
30
31
  return Object.freeze({
31
32
  acceptHandshake: (message)=>session.accept_handshake(message),
33
+ acceptHandshakeWithPayload: (message, payload)=>session.accept_handshake_with_payload(message, payload),
32
34
  encrypt: (aad, plaintext)=>session.encrypt(aad, plaintext),
33
35
  decrypt: (aad, ciphertext)=>session.decrypt(aad, ciphertext),
34
36
  close: ()=>{
@@ -27,6 +27,10 @@ export class Initiator {
27
27
  * Read handshake message two.
28
28
  */
29
29
  finish_handshake(message: Uint8Array): void;
30
+ /**
31
+ * Read handshake message two and return its authenticated payload.
32
+ */
33
+ finish_handshake_with_payload(message: Uint8Array): Uint8Array;
30
34
  /**
31
35
  * Create an invoking-peer state.
32
36
  */
@@ -43,6 +47,10 @@ export class Responder {
43
47
  * Read handshake one, write handshake two, and enter transport mode.
44
48
  */
45
49
  accept_handshake(message: Uint8Array): Uint8Array;
50
+ /**
51
+ * Read handshake one and authenticate a payload inside handshake two.
52
+ */
53
+ accept_handshake_with_payload(message: Uint8Array, payload: Uint8Array): Uint8Array;
46
54
  /**
47
55
  * Dispose session state.
48
56
  */
@@ -83,9 +91,11 @@ export interface InitOutput {
83
91
  readonly initiator_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
84
92
  readonly initiator_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
85
93
  readonly initiator_finish_handshake: (a: number, b: number, c: number) => [number, number];
94
+ readonly initiator_finish_handshake_with_payload: (a: number, b: number, c: number) => [number, number, number, number];
86
95
  readonly initiator_new: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
87
96
  readonly noise_profile: () => [number, number];
88
97
  readonly responder_accept_handshake: (a: number, b: number, c: number) => [number, number, number, number];
98
+ readonly responder_accept_handshake_with_payload: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
89
99
  readonly responder_close: (a: number) => void;
90
100
  readonly responder_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
91
101
  readonly responder_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
@@ -83,6 +83,22 @@ export class Initiator {
83
83
  throw takeFromExternrefTable0(ret[0]);
84
84
  }
85
85
  }
86
+ /**
87
+ * Read handshake message two and return its authenticated payload.
88
+ * @param {Uint8Array} message
89
+ * @returns {Uint8Array}
90
+ */
91
+ finish_handshake_with_payload(message) {
92
+ const ptr0 = passArray8ToWasm0(message, wasm.__wbindgen_malloc);
93
+ const len0 = WASM_VECTOR_LEN;
94
+ const ret = wasm.initiator_finish_handshake_with_payload(this.__wbg_ptr, ptr0, len0);
95
+ if (ret[3]) {
96
+ throw takeFromExternrefTable0(ret[2]);
97
+ }
98
+ var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
99
+ wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
100
+ return v2;
101
+ }
86
102
  /**
87
103
  * Create an invoking-peer state.
88
104
  * @param {Uint8Array} prologue
@@ -137,6 +153,25 @@ export class Responder {
137
153
  wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
138
154
  return v2;
139
155
  }
156
+ /**
157
+ * Read handshake one and authenticate a payload inside handshake two.
158
+ * @param {Uint8Array} message
159
+ * @param {Uint8Array} payload
160
+ * @returns {Uint8Array}
161
+ */
162
+ accept_handshake_with_payload(message, payload) {
163
+ const ptr0 = passArray8ToWasm0(message, wasm.__wbindgen_malloc);
164
+ const len0 = WASM_VECTOR_LEN;
165
+ const ptr1 = passArray8ToWasm0(payload, wasm.__wbindgen_malloc);
166
+ const len1 = WASM_VECTOR_LEN;
167
+ const ret = wasm.responder_accept_handshake_with_payload(this.__wbg_ptr, ptr0, len0, ptr1, len1);
168
+ if (ret[3]) {
169
+ throw takeFromExternrefTable0(ret[2]);
170
+ }
171
+ var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
172
+ wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
173
+ return v3;
174
+ }
140
175
  /**
141
176
  * Dispose session state.
142
177
  */
@@ -9,9 +9,11 @@ export const initiator_close: (a: number) => void;
9
9
  export const initiator_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
10
10
  export const initiator_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
11
11
  export const initiator_finish_handshake: (a: number, b: number, c: number) => [number, number];
12
+ export const initiator_finish_handshake_with_payload: (a: number, b: number, c: number) => [number, number, number, number];
12
13
  export const initiator_new: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
13
14
  export const noise_profile: () => [number, number];
14
15
  export const responder_accept_handshake: (a: number, b: number, c: number) => [number, number, number, number];
16
+ export const responder_accept_handshake_with_payload: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
15
17
  export const responder_close: (a: number) => void;
16
18
  export const responder_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
17
19
  export const responder_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
@@ -2,7 +2,10 @@ import { buildInvocationAad, buildSessionPrologue, permitsCapability } from "@ge
2
2
  import { BridgePeerError } from "../core/mod.js";
3
3
  import { decodeBase64url, encodeBase64url, parseRelayGrantClaims } from "./encoding.js";
4
4
  import { RelayFrameChannel } from "./frame-channel.js";
5
+ import { createUnarySessionCache } from "./unary-session-cache.js";
5
6
  const encoder = new TextEncoder();
7
+ const unaryReuseHandshakePayload = encoder.encode("bridge.unary-reuse.v1");
8
+ const unaryReuseSessionPrefix = "sesr1_";
6
9
  const bridgeCodes = new Set([
7
10
  "OK",
8
11
  "TARGET_OFFLINE",
@@ -23,6 +26,9 @@ const bridgeCodes = new Set([
23
26
  const newId = (prefix)=>`${prefix}_${crypto.randomUUID()}`;
24
27
  const remaining = (deadlineAt)=>Math.max(1, deadlineAt - Date.now());
25
28
  const maxStreamPlaintextChunkBytes = 48 * 1_024;
29
+ /** Maximum number of completed direct unary sessions retained by one live peer. */ const maxIdleUnarySessions = 32;
30
+ /** Short initiator idle window used for immediate sequential unary bursts. */ const unarySessionIdleMs = 1_000;
31
+ /** Responder margin that remains open after the initiator stops session reuse. */ const unaryResponderIdleMs = 2_000;
26
32
  const encodeResponseMetadata = (response)=>{
27
33
  const value = Object.freeze({
28
34
  status: response.status,
@@ -118,6 +124,7 @@ const obtainGrant = async (input, identity)=>{
118
124
  throw new BridgePeerError("GRANT_FAILED");
119
125
  }
120
126
  };
127
+ const equalBytes = (left, right)=>left.length === right.length && left.every((value, index)=>value === right[index]);
121
128
  class RelayConnection {
122
129
  #input;
123
130
  #channel;
@@ -136,6 +143,11 @@ class RelayConnection {
136
143
  #activeIncomingReaders = new Set();
137
144
  #activeResponders = new Set();
138
145
  #exposureReplacements = new Map();
146
+ #idleUnarySessions = createUnarySessionCache({
147
+ capacity: maxIdleUnarySessions,
148
+ idleMs: unarySessionIdleMs,
149
+ close: (session)=>session.machine.close()
150
+ });
139
151
  constructor(input){
140
152
  this.#input = input.request;
141
153
  this.#channel = input.channel;
@@ -181,39 +193,49 @@ class RelayConnection {
181
193
  ]));
182
194
  }
183
195
  async invokeUnary(input) {
184
- const session = await this.#openSession(input, false, undefined, input.signal);
185
- try {
196
+ const sessionKey = `${input.targetPeer}\u0000${input.capability}`;
197
+ for(let attempt = 0; attempt < 2; attempt += 1){
186
198
  const invocationId = newId("inv");
187
- const header = this.#startHeader(session.context, invocationId, input, false);
188
- const ciphertext = encodeBase64url(session.machine.encrypt(buildInvocationAad(header), input.payload));
189
- let dispatched = false;
190
- const cancel = ()=>{
191
- if (dispatched) void this.#sendCancel(session, header).catch(()=>undefined);
192
- };
193
- input.signal?.addEventListener("abort", cancel, {
194
- once: true
195
- });
199
+ const session = this.#idleUnarySessions.take(sessionKey) ?? await this.#openSession(input, false, undefined, input.signal, true);
200
+ let reusable = false;
196
201
  try {
197
- assertNotAborted(input.signal);
198
- this.#channel.send({
199
- ...header,
200
- ciphertext
202
+ const header = this.#startHeader(session.context, invocationId, input, false);
203
+ const ciphertext = encodeBase64url(session.machine.encrypt(buildInvocationAad(header), input.payload));
204
+ let dispatched = false;
205
+ const cancel = ()=>{
206
+ if (dispatched) void this.#sendCancel(session, header).catch(()=>undefined);
207
+ };
208
+ input.signal?.addEventListener("abort", cancel, {
209
+ once: true
201
210
  });
202
- dispatched = true;
203
- if (input.signal?.aborted) cancel();
204
- const terminal = await this.#channel.waitFor((frame)=>frame.type === "invoke.result" && frame.invocationId === invocationId || frame.type === "relay.error" && frame.error.correlationId === invocationId, remaining(input.deadlineAt), input.signal);
205
- if (terminal.type === "relay.error") throw relayFailure(terminal);
206
- const plaintext = session.machine.decrypt(buildInvocationAad(terminal), decodeBase64url(terminal.ciphertext));
207
- if (terminal.outcome !== "succeeded") {
208
- throw new BridgePeerError(terminal.bridgeCode);
211
+ try {
212
+ assertNotAborted(input.signal);
213
+ this.#channel.send({
214
+ ...header,
215
+ ciphertext
216
+ });
217
+ dispatched = true;
218
+ if (input.signal?.aborted) cancel();
219
+ const terminal = await this.#channel.waitFor((frame)=>frame.type === "invoke.result" && frame.invocationId === invocationId || frame.type === "relay.error" && frame.error.correlationId === invocationId, remaining(input.deadlineAt), input.signal);
220
+ if (terminal.type === "relay.error") {
221
+ if (attempt === 0 && terminal.error.bridgeCode === "CONNECTION_LOST" && terminal.error.retryable) continue;
222
+ throw relayFailure(terminal);
223
+ }
224
+ const plaintext = session.machine.decrypt(buildInvocationAad(terminal), decodeBase64url(terminal.ciphertext));
225
+ reusable = session.reusable;
226
+ if (terminal.outcome !== "succeeded") {
227
+ throw new BridgePeerError(terminal.bridgeCode);
228
+ }
229
+ return plaintext;
230
+ } finally{
231
+ input.signal?.removeEventListener("abort", cancel);
209
232
  }
210
- return plaintext;
211
233
  } finally{
212
- input.signal?.removeEventListener("abort", cancel);
234
+ if (reusable && !this.#closed) this.#idleUnarySessions.put(sessionKey, session);
235
+ else session.machine.close();
213
236
  }
214
- } finally{
215
- session.machine.close();
216
237
  }
238
+ throw new BridgePeerError("CONNECTION_LOST", true);
217
239
  }
218
240
  async invokeStream(input) {
219
241
  const session = await this.#openSession(input, true, undefined, input.signal);
@@ -434,13 +456,14 @@ class RelayConnection {
434
456
  for (const timer of this.#exposureReplacements.values())clearTimeout(timer);
435
457
  this.#exposureReplacements.clear();
436
458
  this.#releaseIncoming();
459
+ this.#idleUnarySessions.clear();
437
460
  this.#exposures.clear();
438
461
  this.#channel.close();
439
462
  return Promise.resolve();
440
463
  }
441
- async #openSession(input, stream, expectedTargetEpoch, signal) {
464
+ async #openSession(input, stream, expectedTargetEpoch, signal, requestUnaryReuse = false) {
442
465
  assertNotAborted(signal);
443
- const sessionId = newId("ses");
466
+ const sessionId = newId(requestUnaryReuse ? "sesr1" : "ses");
444
467
  this.#channel.send({
445
468
  version: "json.v1",
446
469
  type: "session.open",
@@ -474,11 +497,13 @@ class RelayConnection {
474
497
  message: encodeBase64url(machine.beginHandshake())
475
498
  });
476
499
  const second = await this.#channel.waitFor((frame)=>frame.type === "session.handshake" && frame.step === 2 && frame.context.sessionId === sessionId, remaining(input.deadlineAt), signal);
477
- machine.finishHandshake(decodeBase64url(second.message));
500
+ const handshake = decodeBase64url(second.message);
501
+ const negotiatedPayload = machine.finishHandshakeWithPayload === undefined ? (machine.finishHandshake(handshake), undefined) : machine.finishHandshakeWithPayload(handshake);
478
502
  assertNotAborted(signal);
479
503
  return Object.freeze({
480
504
  context: ready.context,
481
- machine
505
+ machine,
506
+ reusable: requestUnaryReuse && negotiatedPayload !== undefined && equalBytes(negotiatedPayload, unaryReuseHandshakePayload)
482
507
  });
483
508
  } catch (error) {
484
509
  machine.close();
@@ -573,7 +598,9 @@ class RelayConnection {
573
598
  this.#activeResponders.add(machine);
574
599
  try {
575
600
  const first = await this.#channel.waitFor((frame)=>frame.type === "session.handshake" && frame.step === 1 && frame.context.sessionId === incoming.context.sessionId, this.#timeoutMs);
576
- const second = machine.acceptHandshake(decodeBase64url(first.message));
601
+ const reuseRequested = incoming.context.sessionId.startsWith(unaryReuseSessionPrefix) && machine.acceptHandshakeWithPayload !== undefined;
602
+ const firstMessage = decodeBase64url(first.message);
603
+ const second = reuseRequested ? machine.acceptHandshakeWithPayload(firstMessage, unaryReuseHandshakePayload) : machine.acceptHandshake(firstMessage);
577
604
  this.#channel.send({
578
605
  version: "json.v1",
579
606
  type: "session.handshake",
@@ -583,10 +610,19 @@ class RelayConnection {
583
610
  step: 2,
584
611
  message: encodeBase64url(second)
585
612
  });
586
- const start = await this.#channel.waitFor((frame)=>frame.type === "invoke.start" && frame.sessionId === incoming.context.sessionId, this.#timeoutMs);
587
- if (start.stream) {
588
- await this.#handleIncomingStream(machine, exposure, start);
589
- } else await this.#handleIncomingUnary(machine, exposure, start);
613
+ while(!this.#closed){
614
+ const start = await this.#channel.waitFor((frame)=>frame.type === "invoke.start" && frame.sessionId === incoming.context.sessionId, unaryResponderIdleMs);
615
+ const currentExposure = this.#exposures.get(incoming.capability);
616
+ if (currentExposure === undefined || currentExposure.stream !== start.stream) {
617
+ return;
618
+ }
619
+ if (start.stream) {
620
+ await this.#handleIncomingStream(machine, currentExposure, start);
621
+ return;
622
+ }
623
+ await this.#handleIncomingUnary(machine, currentExposure, start);
624
+ if (start.fanoutId !== undefined || !reuseRequested) return;
625
+ }
590
626
  } finally{
591
627
  this.#activeResponders.delete(machine);
592
628
  machine.close();
@@ -831,6 +867,7 @@ class RelayConnection {
831
867
  if (this.#heartbeatTimer !== undefined) clearTimeout(this.#heartbeatTimer);
832
868
  if (this.#renewalTimer !== undefined) clearTimeout(this.#renewalTimer);
833
869
  this.#releaseIncoming();
870
+ this.#idleUnarySessions.clear();
834
871
  this.#input.onDisconnect(new BridgePeerError("CONNECTION_LOST", true));
835
872
  }
836
873
  #releaseIncoming() {
@@ -0,0 +1,51 @@
1
+ /** One exclusive idle-value cache with bounded capacity and expiry. */ /** Creates a cache that grants one caller exclusive ownership of each stored value. */ export const createUnarySessionCache = (options)=>{
2
+ if (!Number.isSafeInteger(options.capacity) || options.capacity <= 0) {
3
+ throw new Error("invalid_unary_session_capacity");
4
+ }
5
+ if (!Number.isSafeInteger(options.idleMs) || options.idleMs <= 0) {
6
+ throw new Error("invalid_unary_session_idle_ms");
7
+ }
8
+ const entries = new Map();
9
+ let closed = false;
10
+ const remove = (key, expected)=>{
11
+ const entry = entries.get(key);
12
+ if (entry === undefined || expected !== undefined && entry !== expected) return;
13
+ entries.delete(key);
14
+ clearTimeout(entry.timer);
15
+ options.close(entry.value);
16
+ };
17
+ return Object.freeze({
18
+ take: (key)=>{
19
+ const entry = entries.get(key);
20
+ if (entry === undefined) return undefined;
21
+ entries.delete(key);
22
+ clearTimeout(entry.timer);
23
+ return entry.value;
24
+ },
25
+ put: (key, value)=>{
26
+ if (closed) {
27
+ options.close(value);
28
+ return;
29
+ }
30
+ remove(key);
31
+ while(entries.size >= options.capacity){
32
+ remove(entries.keys().next().value);
33
+ }
34
+ const entry = {
35
+ value,
36
+ timer: setTimeout(()=>remove(key, entry), options.idleMs)
37
+ };
38
+ entries.set(key, entry);
39
+ },
40
+ clear: ()=>{
41
+ if (closed) return;
42
+ closed = true;
43
+ for (const key of [
44
+ ...entries.keys()
45
+ ])remove(key);
46
+ },
47
+ get size () {
48
+ return entries.size;
49
+ }
50
+ });
51
+ };