@floegence/flowersec-core 0.22.1 → 0.24.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.
@@ -2,13 +2,13 @@ import { ed25519 } from "@noble/curves/ed25519";
2
2
  import { base64urlEncode } from "../utils/base64url.js";
3
3
  import { signToken } from "./token.js";
4
4
  export class IssuerKeyset {
5
- kid;
6
- signingSeed;
5
+ active;
6
+ verificationKeys = new Map();
7
7
  constructor(kid, signingSeed) {
8
- this.kid = normalizeKid(kid);
9
- if (signingSeed.length !== 32)
10
- throw new TypeError("Ed25519 signing seed must be 32 bytes");
11
- this.signingSeed = signingSeed.slice();
8
+ const normalizedKID = normalizeKid(kid);
9
+ const retainedSeed = copySigningSeed(signingSeed);
10
+ this.active = { kid: normalizedKID, signingSeed: retainedSeed };
11
+ this.verificationKeys.set(normalizedKID, ed25519.getPublicKey(retainedSeed));
12
12
  }
13
13
  static random(kid) {
14
14
  const seed = crypto.getRandomValues(new Uint8Array(32));
@@ -20,30 +20,90 @@ export class IssuerKeyset {
20
20
  }
21
21
  }
22
22
  currentKID() {
23
- return this.kid;
23
+ return this.requireActive().kid;
24
24
  }
25
25
  publicKeys() {
26
- return new Map([[this.kid, ed25519.getPublicKey(this.signingSeed)]]);
26
+ this.requireActive();
27
+ return new Map([...this.verificationKeys.entries()]
28
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
29
+ .map(([kid, publicKey]) => [kid, publicKey.slice()]));
27
30
  }
28
31
  sign(payload) {
29
- return signToken(this.signingSeed, { ...payload, kid: this.kid });
32
+ const active = this.requireActive();
33
+ return signToken(active.signingSeed, { ...payload, kid: active.kid });
34
+ }
35
+ addVerificationKey(kid, publicKey) {
36
+ this.requireActive();
37
+ const normalizedKID = normalizeKid(kid);
38
+ const retainedKey = copyPublicKey(publicKey);
39
+ const existing = this.verificationKeys.get(normalizedKID);
40
+ if (existing != null) {
41
+ if (!equalBytes(existing, retainedKey))
42
+ throw new Error(`key ID ${normalizedKID} is already bound to a different public key`);
43
+ return;
44
+ }
45
+ this.verificationKeys.set(normalizedKID, retainedKey);
30
46
  }
31
47
  rotate(kid, signingSeed) {
48
+ const active = this.requireActive();
32
49
  const nextKID = normalizeKid(kid);
33
- if (signingSeed.length !== 32)
34
- throw new TypeError("Ed25519 signing seed must be 32 bytes");
35
- const nextSeed = signingSeed.slice();
36
- this.signingSeed.fill(0);
37
- this.kid = nextKID;
38
- this.signingSeed = nextSeed;
50
+ const nextSeed = copySigningSeed(signingSeed);
51
+ const nextPublicKey = ed25519.getPublicKey(nextSeed);
52
+ const prepublishedKey = this.verificationKeys.get(nextKID);
53
+ if (prepublishedKey == null) {
54
+ nextSeed.fill(0);
55
+ throw new Error(`key ID ${nextKID} must be prepublished before rotation`);
56
+ }
57
+ if (!equalBytes(prepublishedKey, nextPublicKey)) {
58
+ nextSeed.fill(0);
59
+ throw new Error(`key ID ${nextKID} is bound to a different public key`);
60
+ }
61
+ this.active = { kid: nextKID, signingSeed: nextSeed };
62
+ active.signingSeed.fill(0);
63
+ }
64
+ retireVerificationKey(kid) {
65
+ const active = this.requireActive();
66
+ const normalizedKID = normalizeKid(kid);
67
+ if (normalizedKID === active.kid)
68
+ throw new Error("the active signing key cannot be retired");
69
+ if (!this.verificationKeys.delete(normalizedKID))
70
+ throw new Error(`key ID ${normalizedKID} is not published`);
39
71
  }
40
72
  exportTunnelKeyset() {
41
73
  const keys = [...this.publicKeys()].map(([kid, publicKey]) => ({ kid, pubkey_b64u: base64urlEncode(publicKey) }));
42
74
  return new TextEncoder().encode(JSON.stringify({ keys }, null, 2));
43
75
  }
44
76
  dispose() {
45
- this.signingSeed.fill(0);
77
+ const active = this.active;
78
+ if (active == null)
79
+ return;
80
+ active.signingSeed.fill(0);
81
+ this.active = undefined;
82
+ this.verificationKeys.clear();
46
83
  }
84
+ requireActive() {
85
+ if (this.active == null)
86
+ throw new Error("issuer keyset is disposed");
87
+ return this.active;
88
+ }
89
+ }
90
+ function copySigningSeed(signingSeed) {
91
+ if (signingSeed.length !== 32)
92
+ throw new TypeError("Ed25519 signing seed must be 32 bytes");
93
+ return signingSeed.slice();
94
+ }
95
+ function copyPublicKey(publicKey) {
96
+ if (publicKey.length !== 32)
97
+ throw new TypeError("Ed25519 public key must be 32 bytes");
98
+ return publicKey.slice();
99
+ }
100
+ function equalBytes(left, right) {
101
+ if (left.length !== right.length)
102
+ return false;
103
+ let diff = 0;
104
+ for (let index = 0; index < left.length; index++)
105
+ diff |= left[index] ^ right[index];
106
+ return diff === 0;
47
107
  }
48
108
  function normalizeKid(kid) {
49
109
  const value = kid.trim();
@@ -77,7 +77,9 @@ async function readControlplaneText(response, maxBytes) {
77
77
  try {
78
78
  await reader.cancel();
79
79
  }
80
- catch { }
80
+ catch {
81
+ // The size violation is authoritative; cancellation is secondary cleanup.
82
+ }
81
83
  throw new ControlplaneResponseTooLargeError(maxBytes);
82
84
  }
83
85
  text += decoder.decode(chunk.value, { stream: true });
@@ -8,6 +8,7 @@ export declare const SDK_DEFAULTS: Readonly<{
8
8
  maxHandshakePayloadBytes: number;
9
9
  maxRecordBytes: number;
10
10
  outboundRecordChunkBytes: number;
11
+ maxInboundBufferedBytes: number;
11
12
  maxOutboundBufferedBytes: number;
12
13
  }>;
13
14
  yamux: Readonly<{
@@ -15,6 +16,7 @@ export declare const SDK_DEFAULTS: Readonly<{
15
16
  maxInboundStreams: 32;
16
17
  maxFrameBytes: number;
17
18
  preferredOutboundFrameBytes: number;
19
+ maxStreamWriteQueueBytes: number;
18
20
  maxStreamReceiveBytes: number;
19
21
  maxSessionReceiveBytes: number;
20
22
  }>;
package/dist/defaults.js CHANGED
@@ -8,6 +8,7 @@ export const SDK_DEFAULTS = Object.freeze({
8
8
  maxHandshakePayloadBytes: 8 * 1024,
9
9
  maxRecordBytes: 1024 * 1024,
10
10
  outboundRecordChunkBytes: 64 * 1024,
11
+ maxInboundBufferedBytes: 4 * 1024 * 1024,
11
12
  maxOutboundBufferedBytes: 4 * 1024 * 1024,
12
13
  }),
13
14
  yamux: Object.freeze({
@@ -15,6 +16,7 @@ export const SDK_DEFAULTS = Object.freeze({
15
16
  maxInboundStreams: 32,
16
17
  maxFrameBytes: 256 * 1024,
17
18
  preferredOutboundFrameBytes: 64 * 1024,
19
+ maxStreamWriteQueueBytes: 4 * 1024 * 1024,
18
20
  maxStreamReceiveBytes: 256 * 1024,
19
21
  maxSessionReceiveBytes: 16 * 1024 * 1024,
20
22
  }),
@@ -16,7 +16,7 @@ export type HandshakeClientOptions = Readonly<{
16
16
  maxRecordBytes: number;
17
17
  /** Preferred plaintext bytes per outbound record. */
18
18
  outboundRecordChunkBytes?: number;
19
- /** Maximum buffered plaintext bytes for the secure channel. */
19
+ /** Maximum queued inbound plaintext bytes for the secure channel. */
20
20
  maxBufferedBytes?: number;
21
21
  /** Maximum queued outbound plaintext bytes for the secure channel. */
22
22
  maxOutboundBufferedBytes?: number;
@@ -44,7 +44,7 @@ export type HandshakeServerOptions = Readonly<{
44
44
  maxRecordBytes: number;
45
45
  /** Preferred plaintext bytes per outbound record. */
46
46
  outboundRecordChunkBytes?: number;
47
- /** Maximum buffered plaintext bytes for the secure channel. */
47
+ /** Maximum queued inbound plaintext bytes for the secure channel. */
48
48
  maxBufferedBytes?: number;
49
49
  /** Maximum queued outbound plaintext bytes for the secure channel. */
50
50
  maxOutboundBufferedBytes?: number;
@@ -31,7 +31,7 @@ export declare class SecureChannel {
31
31
  private readonly transport;
32
32
  private readonly maxRecordBytes;
33
33
  private readonly outboundRecordChunkBytes;
34
- private readonly maxBufferedBytes;
34
+ private readonly maxInboundBufferedBytes;
35
35
  private readonly maxOutboundBufferedBytes;
36
36
  private sendKey;
37
37
  private recvKey;
@@ -50,7 +50,7 @@ export declare class SecureChannel {
50
50
  private sendQueueBytes;
51
51
  private sendClosed;
52
52
  private sendErr;
53
- private readonly recvQueue;
53
+ private recvQueue;
54
54
  private recvQueueHead;
55
55
  private recvQueueBytes;
56
56
  private recvWaiters;
@@ -1,6 +1,7 @@
1
1
  import { RECORD_FLAG_APP, RECORD_FLAG_PING, RECORD_FLAG_REKEY } from "./constants.js";
2
2
  import { decryptRecord, encryptRecord, maxPlaintextBytes } from "./record.js";
3
3
  import { deriveRekeyKey } from "./kdf.js";
4
+ import { SDK_DEFAULTS } from "../defaults.js";
4
5
  const maxRecordSeq = (1n << 64n) - 1n;
5
6
  class RecordSeqExhaustedError extends Error {
6
7
  constructor() {
@@ -15,8 +16,7 @@ export class SecureChannel {
15
16
  // Maximum allowed bytes per record frame.
16
17
  maxRecordBytes;
17
18
  outboundRecordChunkBytes;
18
- // Upper bound for buffered plaintext in memory.
19
- maxBufferedBytes;
19
+ maxInboundBufferedBytes;
20
20
  maxOutboundBufferedBytes;
21
21
  // Active encryption keys and nonce prefixes for the current epoch.
22
22
  sendKey;
@@ -52,16 +52,16 @@ export class SecureChannel {
52
52
  this.transport = args.transport;
53
53
  this.maxRecordBytes = args.maxRecordBytes;
54
54
  const maxPlain = Math.max(1, maxPlaintextBytes(this.maxRecordBytes));
55
- this.outboundRecordChunkBytes = args.outboundRecordChunkBytes ?? Math.min(64 * 1024, maxPlain);
55
+ this.outboundRecordChunkBytes = args.outboundRecordChunkBytes ?? Math.min(SDK_DEFAULTS.e2ee.outboundRecordChunkBytes, maxPlain);
56
56
  if (!Number.isSafeInteger(this.outboundRecordChunkBytes) || this.outboundRecordChunkBytes <= 0 || this.outboundRecordChunkBytes > maxPlain) {
57
57
  throw new RangeError("outboundRecordChunkBytes must be a positive integer within the record plaintext limit");
58
58
  }
59
- this.maxBufferedBytes = Math.max(0, args.maxBufferedBytes ?? 4 * (1 << 20));
60
- const maxOutboundBufferedBytes = args.maxOutboundBufferedBytes ?? 4 * (1 << 20);
59
+ this.maxInboundBufferedBytes = Math.max(0, args.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes);
60
+ const maxOutboundBufferedBytes = args.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes;
61
61
  if (!Number.isSafeInteger(maxOutboundBufferedBytes) || maxOutboundBufferedBytes < 0) {
62
62
  throw new RangeError("maxOutboundBufferedBytes must be a non-negative safe integer");
63
63
  }
64
- this.maxOutboundBufferedBytes = maxOutboundBufferedBytes === 0 ? 4 * (1 << 20) : maxOutboundBufferedBytes;
64
+ this.maxOutboundBufferedBytes = maxOutboundBufferedBytes === 0 ? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes : maxOutboundBufferedBytes;
65
65
  this.sendKey = args.sendKey;
66
66
  this.recvKey = args.recvKey;
67
67
  this.sendNoncePrefix = args.sendNoncePrefix;
@@ -87,7 +87,11 @@ export class SecureChannel {
87
87
  if (this.recvQueueHead < this.recvQueue.length) {
88
88
  const b = this.recvQueue[this.recvQueueHead];
89
89
  this.recvQueueHead++;
90
- if (this.recvQueueHead > 1024 && this.recvQueueHead * 2 > this.recvQueue.length) {
90
+ if (this.recvQueueHead === this.recvQueue.length) {
91
+ this.recvQueue = [];
92
+ this.recvQueueHead = 0;
93
+ }
94
+ else if (this.recvQueueHead > 1024 && this.recvQueueHead * 2 > this.recvQueue.length) {
91
95
  this.recvQueue.splice(0, this.recvQueueHead);
92
96
  this.recvQueueHead = 0;
93
97
  }
@@ -171,7 +175,11 @@ export class SecureChannel {
171
175
  return null;
172
176
  const req = this.sendQueue[this.sendQueueHead];
173
177
  this.sendQueueHead++;
174
- if (this.sendQueueHead > 1024 && this.sendQueueHead * 2 > this.sendQueue.length) {
178
+ if (this.sendQueueHead === this.sendQueue.length) {
179
+ this.sendQueue = [];
180
+ this.sendQueueHead = 0;
181
+ }
182
+ else if (this.sendQueueHead > 1024 && this.sendQueueHead * 2 > this.sendQueue.length) {
175
183
  this.sendQueue.splice(0, this.sendQueueHead);
176
184
  this.sendQueueHead = 0;
177
185
  }
@@ -182,7 +190,11 @@ export class SecureChannel {
182
190
  return undefined;
183
191
  const w = this.sendWaiters[this.sendWaitersHead];
184
192
  this.sendWaitersHead++;
185
- if (this.sendWaitersHead > 1024 && this.sendWaitersHead * 2 > this.sendWaiters.length) {
193
+ if (this.sendWaitersHead === this.sendWaiters.length) {
194
+ this.sendWaiters = [];
195
+ this.sendWaitersHead = 0;
196
+ }
197
+ else if (this.sendWaitersHead > 1024 && this.sendWaitersHead * 2 > this.sendWaiters.length) {
186
198
  this.sendWaiters.splice(0, this.sendWaitersHead);
187
199
  this.sendWaitersHead = 0;
188
200
  }
@@ -284,7 +296,7 @@ export class SecureChannel {
284
296
  }
285
297
  this.recvSeq = seq + 1n;
286
298
  if (flags === RECORD_FLAG_APP) {
287
- if (this.maxBufferedBytes > 0 && this.recvQueueBytes + plaintext.length > this.maxBufferedBytes) {
299
+ if (this.maxInboundBufferedBytes > 0 && this.recvQueueBytes + plaintext.length > this.maxInboundBufferedBytes) {
288
300
  throw new Error("recv buffer exceeded");
289
301
  }
290
302
  this.recvQueue.push(plaintext);
@@ -70,6 +70,7 @@ export declare class Session {
70
70
  signal?: AbortSignal;
71
71
  }>): Promise<void>;
72
72
  probeLiveness(timeoutMs?: 10000): Promise<number>;
73
+ rekey(): Promise<void>;
73
74
  close(): void;
74
75
  private pushStream;
75
76
  private acceptRawStream;
@@ -8,6 +8,7 @@ import { decodeHandshakeFrame } from "../e2ee/framing.js";
8
8
  import { HANDSHAKE_TYPE_INIT, PROTOCOL_VERSION } from "../e2ee/constants.js";
9
9
  import { readStreamHello, writeStreamHello } from "../streamhello/streamHello.js";
10
10
  import { ByteReader } from "../yamux/byteReader.js";
11
+ import { isYamuxPingTimeoutError } from "../yamux/errors.js";
11
12
  import { YamuxSession } from "../yamux/session.js";
12
13
  import { RpcServer } from "../rpc/server.js";
13
14
  import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
@@ -43,9 +44,10 @@ export class Session {
43
44
  return session;
44
45
  }
45
46
  async openStream(kind, options = {}) {
47
+ const streamKind = normalizeStreamKind(kind, this.path);
46
48
  const stream = await this.mux.openStream(options);
47
49
  try {
48
- await writeStreamHello((bytes) => stream.write(bytes), normalizeStreamKind(kind, this.path));
50
+ await writeStreamHello((bytes) => stream.write(bytes), streamKind);
49
51
  return stream;
50
52
  }
51
53
  catch (error) {
@@ -82,8 +84,27 @@ export class Session {
82
84
  return;
83
85
  }
84
86
  }
85
- probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
86
- return this.mux.probeLiveness(timeoutMs);
87
+ async probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
88
+ try {
89
+ return await this.mux.probeLiveness(timeoutMs);
90
+ }
91
+ catch (error) {
92
+ throw new FlowersecError({
93
+ path: this.path,
94
+ stage: "yamux",
95
+ code: isYamuxPingTimeoutError(error) ? "timeout" : "ping_failed",
96
+ message: "endpoint liveness probe failed",
97
+ cause: error,
98
+ });
99
+ }
100
+ }
101
+ async rekey() {
102
+ try {
103
+ await this.secure.rekeyNow();
104
+ }
105
+ catch (error) {
106
+ throw new FlowersecError({ path: this.path, stage: "secure", code: "rekey_failed", message: "endpoint rekey failed", cause: error });
107
+ }
87
108
  }
88
109
  close() {
89
110
  this.fail(new Error("endpoint session closed"));
@@ -239,7 +260,7 @@ async function establishSession(path, transport, handshake, options, endpointIns
239
260
  maxHandshakePayload: options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes,
240
261
  maxRecordBytes: options.maxRecordBytes ?? SDK_DEFAULTS.e2ee.maxRecordBytes,
241
262
  outboundRecordChunkBytes: options.outboundRecordChunkBytes ?? SDK_DEFAULTS.e2ee.outboundRecordChunkBytes,
242
- maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
263
+ maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes,
243
264
  maxOutboundBufferedBytes: options.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
244
265
  timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
245
266
  ...(options.signal === undefined ? {} : { signal: options.signal }),
@@ -319,7 +340,7 @@ function randomEndpointInstanceId() {
319
340
  function normalizeStreamKind(kind, path) {
320
341
  const value = kind.trim();
321
342
  if (value === "")
322
- throw new FlowersecError({ path, stage: "validate", code: "missing_stream_kind", message: "missing stream kind" });
343
+ throw new FlowersecError({ path, stage: "rpc", code: "missing_stream_kind", message: "missing stream kind" });
323
344
  return value;
324
345
  }
325
346
  function unwrapServerGrant(input) {
@@ -78,11 +78,10 @@ function bridgeWebSocket(runtime, msg, port) {
78
78
  terminal = true;
79
79
  acceptingWrites = false;
80
80
  const err = error instanceof Error ? error : new Error(String(error));
81
- try {
82
- stream?.reset(err);
83
- }
84
- catch {
85
- // Best-effort.
81
+ if (stream != null) {
82
+ void Promise.resolve(stream.reset(err)).catch(() => {
83
+ // The bridge error is already delivered through the terminal response.
84
+ });
86
85
  }
87
86
  try {
88
87
  ac.abort(err.message);
@@ -416,13 +416,13 @@ export function createProxyRuntime(opts) {
416
416
  }
417
417
  if (!respMeta.ok) {
418
418
  const msg = respMeta.error?.message ?? "upstream error";
419
- port.postMessage({ type: "flowersec-proxy:response_error", status: 502, message: msg });
420
- try {
421
- stream.reset(new Error(msg));
422
- }
423
- catch {
424
- // Best-effort.
425
- }
419
+ port.postMessage({
420
+ type: "flowersec-proxy:response_error",
421
+ status: 502,
422
+ ...(respMeta.error?.code === undefined ? {} : { code: respMeta.error.code }),
423
+ message: msg,
424
+ });
425
+ await stream.reset(new Error(msg));
426
426
  stream = null;
427
427
  return;
428
428
  }
@@ -455,14 +455,10 @@ export function createProxyRuntime(opts) {
455
455
  port.postMessage({
456
456
  type: "flowersec-proxy:response_error",
457
457
  status,
458
+ ...(code === undefined ? {} : { code }),
458
459
  message: msg,
459
460
  });
460
- try {
461
- stream?.reset(new Error(msg));
462
- }
463
- catch {
464
- // Best-effort.
465
- }
461
+ await stream?.reset(new Error(msg));
466
462
  }
467
463
  finally {
468
464
  wakeResponseCreditWaiter();
@@ -494,12 +490,7 @@ export function createProxyRuntime(opts) {
494
490
  const resp = (await readJsonFrame(reader, maxJsonFrameBytes));
495
491
  if (resp.v !== PROXY_PROTOCOL_VERSION || resp.ok !== true) {
496
492
  const msg = resp.error?.message ?? "upstream ws open failed";
497
- try {
498
- stream.reset(new Error(msg));
499
- }
500
- catch {
501
- // Best-effort.
502
- }
493
+ await stream.reset(new Error(msg));
503
494
  throw new Error(msg);
504
495
  }
505
496
  return { stream, protocol: resp.protocol ?? "" };