@layervai/qurl 0.7.0 → 2.0.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.
Files changed (121) hide show
  1. package/README.md +212 -1
  2. package/dist/cjs/client.d.ts +19 -1
  3. package/dist/cjs/client.js +520 -9
  4. package/dist/cjs/crid.d.ts +2 -1
  5. package/dist/cjs/crid.js +11 -2
  6. package/dist/cjs/errors.d.ts +20 -3
  7. package/dist/cjs/errors.js +31 -4
  8. package/dist/cjs/index.d.ts +2 -2
  9. package/dist/cjs/index.js +3 -1
  10. package/dist/cjs/node/agent-runtime.d.ts +108 -0
  11. package/dist/cjs/node/agent-runtime.js +952 -0
  12. package/dist/cjs/node/agent-state.d.ts +100 -0
  13. package/dist/cjs/node/agent-state.js +311 -0
  14. package/dist/cjs/node/agent-transport.d.ts +17 -0
  15. package/dist/cjs/node/agent-transport.js +118 -0
  16. package/dist/cjs/node/deployment.d.ts +6 -0
  17. package/dist/cjs/node/deployment.js +24 -3
  18. package/dist/cjs/node/file-agent-state.d.ts +15 -0
  19. package/dist/cjs/node/file-agent-state.js +137 -0
  20. package/dist/cjs/node/native-loader.cjs +7 -0
  21. package/dist/cjs/node/native-loader.d.cts +11 -0
  22. package/dist/cjs/node/native-udp.d.ts +8 -3
  23. package/dist/cjs/node/native-udp.js +8 -3
  24. package/dist/cjs/node/nhp-wire.d.ts +9 -0
  25. package/dist/cjs/node/nhp-wire.js +24 -7
  26. package/dist/cjs/node/portal-opener.d.ts +7 -0
  27. package/dist/cjs/node/portal-opener.js +28 -6
  28. package/dist/cjs/node/provider.d.ts +19 -0
  29. package/dist/cjs/node/provider.js +153 -0
  30. package/dist/cjs/node/qv2.d.ts +3 -1
  31. package/dist/cjs/node/qv2.js +16 -2
  32. package/dist/cjs/node/relay.d.ts +12 -0
  33. package/dist/cjs/node/relay.js +111 -0
  34. package/dist/cjs/node/sealed-agent-state.d.ts +19 -0
  35. package/dist/cjs/node/sealed-agent-state.js +193 -0
  36. package/dist/cjs/node/strict-json.d.ts +1 -1
  37. package/dist/cjs/node/strict-json.js +8 -3
  38. package/dist/cjs/node.d.ts +14 -0
  39. package/dist/cjs/node.js +24 -1
  40. package/dist/cjs/types.d.ts +97 -0
  41. package/dist/cjs/version.d.ts +1 -1
  42. package/dist/cjs/version.js +1 -1
  43. package/dist/esm/client.d.ts +19 -1
  44. package/dist/esm/client.d.ts.map +1 -1
  45. package/dist/esm/client.js +521 -10
  46. package/dist/esm/client.js.map +1 -1
  47. package/dist/esm/crid.d.ts +2 -1
  48. package/dist/esm/crid.d.ts.map +1 -1
  49. package/dist/esm/crid.js +10 -2
  50. package/dist/esm/crid.js.map +1 -1
  51. package/dist/esm/errors.d.ts +20 -3
  52. package/dist/esm/errors.d.ts.map +1 -1
  53. package/dist/esm/errors.js +29 -3
  54. package/dist/esm/errors.js.map +1 -1
  55. package/dist/esm/index.d.ts +2 -2
  56. package/dist/esm/index.d.ts.map +1 -1
  57. package/dist/esm/index.js +1 -1
  58. package/dist/esm/index.js.map +1 -1
  59. package/dist/esm/node/agent-runtime.d.ts +109 -0
  60. package/dist/esm/node/agent-runtime.d.ts.map +1 -0
  61. package/dist/esm/node/agent-runtime.js +946 -0
  62. package/dist/esm/node/agent-runtime.js.map +1 -0
  63. package/dist/esm/node/agent-state.d.ts +101 -0
  64. package/dist/esm/node/agent-state.d.ts.map +1 -0
  65. package/dist/esm/node/agent-state.js +298 -0
  66. package/dist/esm/node/agent-state.js.map +1 -0
  67. package/dist/esm/node/agent-transport.d.ts +18 -0
  68. package/dist/esm/node/agent-transport.d.ts.map +1 -0
  69. package/dist/esm/node/agent-transport.js +114 -0
  70. package/dist/esm/node/agent-transport.js.map +1 -0
  71. package/dist/esm/node/deployment.d.ts +6 -0
  72. package/dist/esm/node/deployment.d.ts.map +1 -1
  73. package/dist/esm/node/deployment.js +23 -3
  74. package/dist/esm/node/deployment.js.map +1 -1
  75. package/dist/esm/node/file-agent-state.d.ts +16 -0
  76. package/dist/esm/node/file-agent-state.d.ts.map +1 -0
  77. package/dist/esm/node/file-agent-state.js +134 -0
  78. package/dist/esm/node/file-agent-state.js.map +1 -0
  79. package/dist/esm/node/native-loader.cjs +7 -0
  80. package/dist/esm/node/native-loader.d.cts +11 -0
  81. package/dist/esm/node/native-udp.d.ts +8 -3
  82. package/dist/esm/node/native-udp.d.ts.map +1 -1
  83. package/dist/esm/node/native-udp.js +7 -5
  84. package/dist/esm/node/native-udp.js.map +1 -1
  85. package/dist/esm/node/nhp-wire.d.ts +9 -0
  86. package/dist/esm/node/nhp-wire.d.ts.map +1 -1
  87. package/dist/esm/node/nhp-wire.js +23 -6
  88. package/dist/esm/node/nhp-wire.js.map +1 -1
  89. package/dist/esm/node/portal-opener.d.ts +7 -0
  90. package/dist/esm/node/portal-opener.d.ts.map +1 -1
  91. package/dist/esm/node/portal-opener.js +28 -6
  92. package/dist/esm/node/portal-opener.js.map +1 -1
  93. package/dist/esm/node/provider.d.ts +20 -0
  94. package/dist/esm/node/provider.d.ts.map +1 -0
  95. package/dist/esm/node/provider.js +149 -0
  96. package/dist/esm/node/provider.js.map +1 -0
  97. package/dist/esm/node/qv2.d.ts +3 -1
  98. package/dist/esm/node/qv2.d.ts.map +1 -1
  99. package/dist/esm/node/qv2.js +17 -3
  100. package/dist/esm/node/qv2.js.map +1 -1
  101. package/dist/esm/node/relay.d.ts +13 -0
  102. package/dist/esm/node/relay.d.ts.map +1 -0
  103. package/dist/esm/node/relay.js +105 -0
  104. package/dist/esm/node/relay.js.map +1 -0
  105. package/dist/esm/node/sealed-agent-state.d.ts +20 -0
  106. package/dist/esm/node/sealed-agent-state.d.ts.map +1 -0
  107. package/dist/esm/node/sealed-agent-state.js +190 -0
  108. package/dist/esm/node/sealed-agent-state.js.map +1 -0
  109. package/dist/esm/node/strict-json.d.ts +1 -1
  110. package/dist/esm/node/strict-json.d.ts.map +1 -1
  111. package/dist/esm/node/strict-json.js +8 -3
  112. package/dist/esm/node/strict-json.js.map +1 -1
  113. package/dist/esm/node.d.ts +14 -0
  114. package/dist/esm/node.d.ts.map +1 -1
  115. package/dist/esm/node.js +7 -0
  116. package/dist/esm/node.js.map +1 -1
  117. package/dist/esm/types.d.ts +97 -0
  118. package/dist/esm/types.d.ts.map +1 -1
  119. package/dist/esm/version.d.ts +1 -1
  120. package/dist/esm/version.js +1 -1
  121. package/package.json +16 -7
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FileAgentState = void 0;
4
+ const node_path_1 = require("node:path");
5
+ const node_crypto_1 = require("node:crypto");
6
+ const promises_1 = require("node:timers/promises");
7
+ const native_loader_cjs_1 = require("./native-loader.cjs");
8
+ const agent_state_js_1 = require("./agent-state.js");
9
+ const plaintextCodec = {
10
+ async encode(state) {
11
+ return (0, agent_state_js_1.encodeAgentState)(state);
12
+ },
13
+ async decode(raw) {
14
+ return (0, agent_state_js_1.decodeAgentState)(raw);
15
+ },
16
+ };
17
+ /** Own this handle until all clients and lifecycle operations finish. */
18
+ class FileAgentState {
19
+ #native;
20
+ #handle;
21
+ #codec;
22
+ #active = 0;
23
+ #closed = false;
24
+ #locked = false;
25
+ constructor(path, codec = plaintextCodec) {
26
+ let absolute = (0, node_path_1.resolve)(path);
27
+ if (process.platform === "darwin")
28
+ absolute = absolute.replace(/^\/(var|tmp|etc)(?=\/|$)/, "/private/$1");
29
+ this.#native = (0, native_loader_cjs_1.loadNativeStateFS)();
30
+ this.#handle = this.#native.open((0, node_path_1.dirname)(absolute), (0, node_path_1.basename)(absolute));
31
+ this.#codec = codec;
32
+ }
33
+ checkContinuity() {
34
+ if (this.#closed)
35
+ throw new agent_state_js_1.AgentStateError("CLOSED");
36
+ this.#native.check(this.#handle);
37
+ }
38
+ async load(signal) {
39
+ this.checkContinuity();
40
+ signal?.throwIfAborted();
41
+ this.#active++;
42
+ let bytes = null;
43
+ try {
44
+ bytes = this.#native.read(this.#handle);
45
+ if (bytes === null)
46
+ throw new agent_state_js_1.AgentStateError("NOT_FOUND");
47
+ const state = await this.#codec.decode(bytes, signal);
48
+ this.checkContinuity();
49
+ signal?.throwIfAborted();
50
+ return state;
51
+ }
52
+ finally {
53
+ bytes?.fill(0);
54
+ this.#active--;
55
+ }
56
+ }
57
+ async save(state, signal) {
58
+ // Snapshot before waiting for another writer or an external key wrapper.
59
+ const snapshot = structuredClone(state);
60
+ await this.withLock((locked) => locked.save(snapshot, signal), signal);
61
+ }
62
+ async withLock(operation, signal) {
63
+ this.checkContinuity();
64
+ this.#active++;
65
+ let acquired = false;
66
+ const deadline = AbortSignal.timeout(30_000);
67
+ const waiting = signal ? AbortSignal.any([signal, deadline]) : deadline;
68
+ try {
69
+ for (;;) {
70
+ waiting.throwIfAborted();
71
+ if (!this.#locked && this.#native.tryLock(this.#handle)) {
72
+ this.#locked = acquired = true;
73
+ break;
74
+ }
75
+ await (0, promises_1.setTimeout)(25, undefined, { signal: waiting });
76
+ }
77
+ let valid = true;
78
+ const requireLease = () => {
79
+ if (!valid)
80
+ throw new agent_state_js_1.AgentStateError("EXPIRED_LOCK_HANDLE");
81
+ this.checkContinuity();
82
+ };
83
+ const locked = {
84
+ load: async (nextSignal) => {
85
+ requireLease();
86
+ const value = await this.load(nextSignal ?? signal);
87
+ requireLease();
88
+ return value;
89
+ },
90
+ save: async (state, nextSignal) => {
91
+ requireLease();
92
+ const raw = (0, agent_state_js_1.encodeAgentState)(state);
93
+ let encoded;
94
+ try {
95
+ encoded = await this.#codec.encode((0, agent_state_js_1.decodeAgentState)(raw), nextSignal ?? signal);
96
+ requireLease();
97
+ (nextSignal ?? signal)?.throwIfAborted();
98
+ this.#native.write(this.#handle, encoded, `.qurl-${(0, node_crypto_1.randomUUID)()}`);
99
+ }
100
+ finally {
101
+ encoded?.fill(0);
102
+ raw.fill(0);
103
+ }
104
+ },
105
+ withLock: async (fn) => {
106
+ requireLease();
107
+ return fn(locked);
108
+ },
109
+ checkContinuity: requireLease,
110
+ };
111
+ try {
112
+ const result = await operation(locked);
113
+ requireLease();
114
+ return result;
115
+ }
116
+ finally {
117
+ valid = false;
118
+ }
119
+ }
120
+ finally {
121
+ this.#active--;
122
+ if (acquired) {
123
+ this.#locked = false;
124
+ this.#native.unlock(this.#handle);
125
+ }
126
+ }
127
+ }
128
+ close() {
129
+ if (this.#closed)
130
+ return;
131
+ if (this.#active)
132
+ throw new agent_state_js_1.AgentStateError("BUSY");
133
+ this.#closed = true;
134
+ this.#native.close(this.#handle);
135
+ }
136
+ }
137
+ exports.FileAgentState = FileAgentState;
@@ -0,0 +1,7 @@
1
+ const { platform } = require('node:process');
2
+ exports.loadNativeStateFS = () => {
3
+ if (platform !== 'linux' && platform !== 'darwin') {
4
+ throw new Error('Secure qURL state storage requires Linux or macOS');
5
+ }
6
+ return require('@layervai/qurl-state-fs').load();
7
+ };
@@ -0,0 +1,11 @@
1
+ export declare function loadNativeStateFS(): NativeStateFS;
2
+
3
+ export interface NativeStateFS {
4
+ open(directory: string, name: string): object;
5
+ check(handle: object): void;
6
+ tryLock(handle: object): boolean;
7
+ unlock(handle: object): void;
8
+ read(handle: object): Buffer | null;
9
+ write(handle: object, bytes: Buffer, temporaryName: string): void;
10
+ close(handle: object): void;
11
+ }
@@ -17,13 +17,18 @@ interface NativeKnockRuntime {
17
17
  }
18
18
  export declare function nativeKnock(cell: ValidatedCell, devicePrivateKey: Uint8Array, body: Uint8Array, options?: NativeExchangeOptions): Promise<NHPMessage>;
19
19
  declare function nativeKnockWithRuntime(cell: ValidatedCell, devicePrivateKey: Uint8Array, body: Uint8Array, options: NativeExchangeOptions, runtime: NativeKnockRuntime): Promise<NHPMessage>;
20
- declare function resolvePublicAddresses(host: string, maximum: number, signal?: AbortSignal, lookupAddresses?: typeof lookup): Promise<Array<{
20
+ export declare function resolvePublicAddresses(host: string, maximum: number, signal?: AbortSignal, lookupAddresses?: typeof lookup): Promise<Array<{
21
21
  address: string;
22
22
  family: 4 | 6;
23
23
  }>>;
24
24
  declare function isPublicAddress(address: string, family: number): boolean;
25
- declare function exchangeDatagram(address: string, family: 4 | 6, port: number, packet: Uint8Array, timeoutMs: number, signal?: AbortSignal): Promise<Uint8Array>;
26
- declare function sendOwnedDatagram(socket: Socket, packet: Uint8Array, finish: (error: unknown) => void): void;
25
+ declare class SocketExchangeError extends Error {
26
+ readonly cause: unknown;
27
+ constructor(cause: unknown);
28
+ }
29
+ export declare function isSocketExchangeError(error: unknown): error is SocketExchangeError;
30
+ export declare function exchangeDatagram(address: string, family: 4 | 6, port: number, packet: Uint8Array, timeoutMs: number, signal?: AbortSignal, oneWay?: boolean): Promise<Uint8Array>;
31
+ declare function sendOwnedDatagram(socket: Socket, packet: Uint8Array, finish: (error: unknown) => void, onSent?: () => void): void;
27
32
  export declare const nativeUdpTesting: {
28
33
  isPublicAddress: typeof isPublicAddress;
29
34
  resolvePublicAddresses: typeof resolvePublicAddresses;
@@ -2,6 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.nativeUdpTesting = void 0;
4
4
  exports.nativeKnock = nativeKnock;
5
+ exports.resolvePublicAddresses = resolvePublicAddresses;
6
+ exports.isSocketExchangeError = isSocketExchangeError;
7
+ exports.exchangeDatagram = exchangeDatagram;
5
8
  const promises_1 = require("node:dns/promises");
6
9
  const node_dgram_1 = require("node:dgram");
7
10
  const node_net_1 = require("node:net");
@@ -192,7 +195,7 @@ class ReceivedDatagramError extends Error {
192
195
  function isSocketExchangeError(error) {
193
196
  return error instanceof SocketExchangeError;
194
197
  }
195
- function exchangeDatagram(address, family, port, packet, timeoutMs, signal) {
198
+ function exchangeDatagram(address, family, port, packet, timeoutMs, signal, oneWay = false) {
196
199
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
197
200
  return Promise.reject(new Error("native NHP timeout must be from 1 to 60000 milliseconds"));
198
201
  }
@@ -237,11 +240,11 @@ function exchangeDatagram(address, family, port, packet, timeoutMs, signal) {
237
240
  finish(undefined, reply);
238
241
  });
239
242
  socket.connect(port, address, () => {
240
- sendOwnedDatagram(socket, packet, finish);
243
+ sendOwnedDatagram(socket, packet, finish, oneWay ? () => finish(undefined, Buffer.alloc(0)) : undefined);
241
244
  });
242
245
  });
243
246
  }
244
- function sendOwnedDatagram(socket, packet, finish) {
247
+ function sendOwnedDatagram(socket, packet, finish, onSent) {
245
248
  // dgram owns its input until this callback. Use an independent encrypted
246
249
  // datagram so nativeKnock can wipe the builder's packet immediately when an
247
250
  // abort settles the exchange. Wipe this copy only after libuv releases it.
@@ -251,6 +254,8 @@ function sendOwnedDatagram(socket, packet, finish) {
251
254
  try {
252
255
  if (error)
253
256
  finish(error);
257
+ else
258
+ onSent?.();
254
259
  }
255
260
  finally {
256
261
  outbound.fill(0);
@@ -1,6 +1,13 @@
1
1
  export declare const NHP_TYPE_KNOCK = 1;
2
2
  export declare const NHP_TYPE_ACK = 2;
3
3
  export declare const NHP_TYPE_COOKIE = 7;
4
+ export declare const NHP_TYPE_LIST = 5;
5
+ export declare const NHP_TYPE_LIST_RESULT = 6;
6
+ export declare const NHP_TYPE_REKNOCK = 8;
7
+ export declare const NHP_TYPE_OTP = 12;
8
+ export declare const NHP_TYPE_REGISTER = 13;
9
+ export declare const NHP_TYPE_REGISTER_ACK = 14;
10
+ export declare const NHP_TYPE_EXIT = 16;
4
11
  export declare const NHP_HEADER_SIZE = 240;
5
12
  export declare const NHP_PACKET_SIZE = 4096;
6
13
  export declare const NHP_MAX_BODY_SIZE: number;
@@ -20,6 +27,8 @@ export interface BuildNHPMessageInput {
20
27
  readonly timestampNanos?: bigint;
21
28
  readonly counter?: bigint;
22
29
  readonly preamble?: number;
30
+ readonly cookie?: Uint8Array;
31
+ readonly hubProof?: boolean;
23
32
  }
24
33
  export declare function buildNHPMessage(input: BuildNHPMessageInput): {
25
34
  packet: Uint8Array;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.nhpWireTesting = exports.NHP_MAX_BODY_SIZE = exports.NHP_PACKET_SIZE = exports.NHP_HEADER_SIZE = exports.NHP_TYPE_COOKIE = exports.NHP_TYPE_ACK = exports.NHP_TYPE_KNOCK = void 0;
3
+ exports.nhpWireTesting = exports.NHP_MAX_BODY_SIZE = exports.NHP_PACKET_SIZE = exports.NHP_HEADER_SIZE = exports.NHP_TYPE_EXIT = exports.NHP_TYPE_REGISTER_ACK = exports.NHP_TYPE_REGISTER = exports.NHP_TYPE_OTP = exports.NHP_TYPE_REKNOCK = exports.NHP_TYPE_LIST_RESULT = exports.NHP_TYPE_LIST = exports.NHP_TYPE_COOKIE = exports.NHP_TYPE_ACK = exports.NHP_TYPE_KNOCK = void 0;
4
4
  exports.buildNHPMessage = buildNHPMessage;
5
5
  exports.decryptNHPReply = decryptNHPReply;
6
6
  const node_crypto_1 = require("node:crypto");
@@ -8,6 +8,13 @@ const node_zlib_1 = require("node:zlib");
8
8
  exports.NHP_TYPE_KNOCK = 1;
9
9
  exports.NHP_TYPE_ACK = 2;
10
10
  exports.NHP_TYPE_COOKIE = 7;
11
+ exports.NHP_TYPE_LIST = 5;
12
+ exports.NHP_TYPE_LIST_RESULT = 6;
13
+ exports.NHP_TYPE_REKNOCK = 8;
14
+ exports.NHP_TYPE_OTP = 12;
15
+ exports.NHP_TYPE_REGISTER = 13;
16
+ exports.NHP_TYPE_REGISTER_ACK = 14;
17
+ exports.NHP_TYPE_EXIT = 16;
11
18
  exports.NHP_HEADER_SIZE = 240;
12
19
  exports.NHP_PACKET_SIZE = 4_096;
13
20
  exports.NHP_MAX_BODY_SIZE = exports.NHP_PACKET_SIZE - exports.NHP_HEADER_SIZE - 16;
@@ -26,8 +33,12 @@ function buildNHPMessage(input) {
26
33
  validateKey(input.serverPublicKey, "server public key");
27
34
  if (input.body.byteLength > exports.NHP_MAX_BODY_SIZE)
28
35
  throw new Error("NHP application body is too large");
29
- if (input.type !== exports.NHP_TYPE_KNOCK)
36
+ if (![1, 5, 8, 12, 13, 16].includes(input.type))
30
37
  throw new Error("unsupported NHP initiator message type");
38
+ const cookieRequired = input.type === exports.NHP_TYPE_REKNOCK || input.hubProof === true;
39
+ if ((input.hubProof && input.type !== exports.NHP_TYPE_LIST) ||
40
+ (cookieRequired ? input.cookie?.length !== 32 : input.cookie !== undefined))
41
+ throw new Error("invalid NHP cookie proof");
31
42
  const ephemeralPrivateKey = input.ephemeralPrivateKey
32
43
  ? Buffer.from(input.ephemeralPrivateKey)
33
44
  : (0, node_crypto_1.randomBytes)(32);
@@ -68,14 +79,14 @@ function buildNHPMessage(input) {
68
79
  const bodyKey = derived[1];
69
80
  header.writeUInt8(1, 8);
70
81
  header.writeUInt8(1, 9);
71
- header.writeUInt16BE(0, 10);
82
+ header.writeUInt16BE(input.hubProof ? 4 : 0, 10);
72
83
  header.writeBigUInt64BE(counter, 16);
73
84
  const payloadSize = input.body.byteLength === 0 ? 0 : input.body.byteLength + 16;
74
85
  header.writeUInt32BE(preamble >>> 0, 0);
75
86
  header.writeUInt32BE((preamble ^ ((input.type << 16) | payloadSize)) >>> 0, 4);
76
87
  const bodyAad = hashParts([...chainHashParts, header.subarray(0, HEADER_COMMON_SIZE)]);
77
88
  const sealedBody = input.body.byteLength === 0 ? Buffer.alloc(0) : seal(bodyKey, nonce, input.body, bodyAad);
78
- headerDigest(input.serverPublicKey, header).copy(header, OFFSET_DIGEST);
89
+ headerDigest(input.serverPublicKey, header, input.cookie).copy(header, OFFSET_DIGEST);
79
90
  return { packet: Buffer.concat([header, sealedBody]), counter };
80
91
  }
81
92
  finally {
@@ -156,7 +167,8 @@ function decryptNHPReply(devicePrivateKey, expectedServerPublicKey, packetValue)
156
167
  }
157
168
  }
158
169
  function validateReplyProfile(type, flags) {
159
- if (![exports.NHP_TYPE_ACK, exports.NHP_TYPE_COOKIE].includes(type) || (flags !== 0 && flags !== FLAG_COMPRESS)) {
170
+ if (![exports.NHP_TYPE_ACK, exports.NHP_TYPE_COOKIE, exports.NHP_TYPE_LIST_RESULT, exports.NHP_TYPE_REGISTER_ACK].includes(type) ||
171
+ (flags !== 0 && flags !== FLAG_COMPRESS)) {
160
172
  throw new Error("NHP reply type or flags are outside the reply profile");
161
173
  }
162
174
  }
@@ -247,8 +259,13 @@ function open(key, nonce, sealed, aad) {
247
259
  decipher.setAuthTag(tag);
248
260
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
249
261
  }
250
- function headerDigest(peerStaticPublicKey, header) {
251
- return hashParts([INITIAL_HASH, peerStaticPublicKey, header.subarray(0, OFFSET_DIGEST)]);
262
+ function headerDigest(peerStaticPublicKey, header, cookie) {
263
+ return hashParts([
264
+ INITIAL_HASH,
265
+ peerStaticPublicKey,
266
+ header.subarray(0, OFFSET_DIGEST),
267
+ ...(cookie ? [cookie] : []),
268
+ ]);
252
269
  }
253
270
  function trackSecret(secrets, value) {
254
271
  secrets.push(value);
@@ -1,3 +1,4 @@
1
+ import type { PortalProvider } from "./provider.js";
1
2
  import type { NHPMessage } from "./nhp-wire.js";
2
3
  import { type PortalDeployment, type ValidatedCell, type ValidatedDeployment } from "./deployment.js";
3
4
  import { type NativeExchangeOptions } from "./native-udp.js";
@@ -5,8 +6,14 @@ import { type VerifiedQv2Link } from "./qv2.js";
5
6
  export interface CreatePortalOpenerOptions {
6
7
  /** Full qv2t1 qURL. The opener re-verifies it before each native open. */
7
8
  readonly qurl: string;
9
+ /** Independently obtained CRID. When supplied, checked before every native open. */
10
+ readonly expectedCRID?: string;
8
11
  /** Public deployment trust. Omit to load QURL_DEPLOYMENT on the first start. */
9
12
  readonly deployment?: PortalDeployment;
13
+ readonly provider?: PortalProvider;
14
+ /** Explicit choice. Native UDP is the default and never falls back to relay. */
15
+ readonly transport?: "native" | "relay";
16
+ readonly relayFetch?: typeof globalThis.fetch;
10
17
  /** Whole-operation deadline for each NHP open. The default is 15 seconds. */
11
18
  readonly openTimeoutMs?: number;
12
19
  /** Protected-content fetch implementation. Native NHP opening never uses it. */
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.portalOpenerTesting = exports.PortalInvalidReplyError = exports.PortalBusyError = exports.PortalTooManyRedirectsError = exports.PortalRedirectError = exports.PortalTargetChangedError = exports.PortalOpenTimeoutError = exports.PortalOpenerClosedError = exports.PortalOpenerNotReadyError = exports.PortalOpenerNotStartedError = exports.PortalStateError = exports.PortalDenyError = exports.PortalVerificationError = exports.PortalConfigurationError = void 0;
4
4
  exports.createPortalOpener = createPortalOpener;
5
5
  exports.createPortalOpenerWithRuntime = createPortalOpenerWithRuntime;
6
+ const relay_js_1 = require("./relay.js");
6
7
  const node_crypto_1 = require("node:crypto");
7
8
  const node_events_1 = require("node:events");
8
9
  const nhp_wire_js_1 = require("./nhp-wire.js");
@@ -10,6 +11,7 @@ const deployment_js_1 = require("./deployment.js");
10
11
  const native_udp_js_1 = require("./native-udp.js");
11
12
  const strict_json_js_1 = require("./strict-json.js");
12
13
  const qv2_js_1 = require("./qv2.js");
14
+ const crid_js_1 = require("../crid.js");
13
15
  const SESSION_COOKIE = "qurl_vsession";
14
16
  const MAX_REDIRECT_REQUESTS = 10;
15
17
  const DEFAULT_OPEN_TIMEOUT_MS = 15_000;
@@ -146,6 +148,9 @@ function createPortalOpenerWithRuntime(options, runtime) {
146
148
  if (typeof options.qurl !== "string" || options.qurl.trim() === "") {
147
149
  throw new PortalConfigurationError("native portal opener qurl must be a non-empty string");
148
150
  }
151
+ if ("expectedCRID" in options && !(0, crid_js_1.parseCrid)(options.expectedCRID, true)) {
152
+ throw new PortalConfigurationError("native portal opener expectedCRID is invalid or unsupported");
153
+ }
149
154
  if (options.openTimeoutMs !== undefined &&
150
155
  (!Number.isFinite(options.openTimeoutMs) ||
151
156
  options.openTimeoutMs < 1 ||
@@ -155,6 +160,12 @@ function createPortalOpenerWithRuntime(options, runtime) {
155
160
  if (options.fetch !== undefined && typeof options.fetch !== "function") {
156
161
  throw new PortalConfigurationError("native portal opener fetch must be a function");
157
162
  }
163
+ if (options.transport !== undefined &&
164
+ options.transport !== "native" &&
165
+ options.transport !== "relay")
166
+ throw new PortalConfigurationError("unsupported portal transport");
167
+ if (options.provider && options.deployment)
168
+ throw new PortalConfigurationError("configure either a provider or a deployment");
158
169
  return new NativePortalOpener(options, runtime);
159
170
  }
160
171
  class NativePortalOpener {
@@ -162,6 +173,10 @@ class NativePortalOpener {
162
173
  #fetch;
163
174
  #explicitDeployment;
164
175
  #openTimeoutMs;
176
+ #expectedCRID;
177
+ #provider;
178
+ #transport;
179
+ #relayFetch;
165
180
  #qurl;
166
181
  #resolvedDeployment;
167
182
  #sessionSecret;
@@ -183,7 +198,11 @@ class NativePortalOpener {
183
198
  #consecutiveFailures = 0;
184
199
  constructor(options, runtime) {
185
200
  this.#qurl = options.qurl;
201
+ this.#expectedCRID = options.expectedCRID;
186
202
  this.#explicitDeployment = options.deployment;
203
+ this.#provider = options.provider;
204
+ this.#transport = options.transport ?? "native";
205
+ this.#relayFetch = options.relayFetch;
187
206
  this.#runtime = runtime;
188
207
  this.#fetch = options.fetch ?? runtime.fetch;
189
208
  this.#openTimeoutMs = options.openTimeoutMs ?? DEFAULT_OPEN_TIMEOUT_MS;
@@ -475,10 +494,13 @@ class NativePortalOpener {
475
494
  }
476
495
  async #performOpen(signal) {
477
496
  this.#requireOpen();
478
- const deployment = this.#loadDeployment();
497
+ const deployment = this.#provider
498
+ ? (0, deployment_js_1.loadPortalDeployment)(await this.#provider.resolve(signal))
499
+ : this.#loadDeployment();
500
+ signal.throwIfAborted();
479
501
  const link = this.#verifyLink(deployment);
480
502
  try {
481
- const cell = validatedCellForLink(link, deployment);
503
+ const cell = this.#transport === "native" ? validatedCellForLink(link, deployment) : undefined;
482
504
  this.#sessionSecret ??= (0, node_crypto_1.randomBytes)(32);
483
505
  const body = this.#knockBody(link);
484
506
  // Start the local validity bound before DNS and UDP I/O. The cell starts
@@ -486,9 +508,9 @@ class NativePortalOpener {
486
508
  const startedAtNanos = this.#runtime.nowNanos();
487
509
  const startedAtEpochMs = this.#runtime.nowEpochMs();
488
510
  try {
489
- const reply = await this.#runtime.knock(cell, link.devicePrivateKey, body, {
490
- signal,
491
- });
511
+ const reply = cell
512
+ ? await this.#runtime.knock(cell, link.devicePrivateKey, body, { signal })
513
+ : await (0, relay_js_1.relayKnock)(link.claims.relayUrl, deployment.relayAllowlist, link.claims.cellPublicKey, link.devicePrivateKey, body, { signal, fetch: this.#relayFetch });
492
514
  try {
493
515
  this.#requireOpen();
494
516
  if (reply.type === nhp_wire_js_1.NHP_TYPE_COOKIE)
@@ -674,7 +696,7 @@ class NativePortalOpener {
674
696
  }
675
697
  #verifyLink(deployment) {
676
698
  try {
677
- return (0, qv2_js_1.verifyQv2Link)(this.#qurl, deployment.issuers);
699
+ return (0, qv2_js_1.verifyQv2Link)(this.#qurl, deployment.issuers, this.#expectedCRID);
678
700
  }
679
701
  catch (error) {
680
702
  throw new PortalVerificationError("native qURL credential validation failed", {
@@ -0,0 +1,19 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ import { type PortalDeployment } from "./deployment.js";
3
+ export interface PortalProvider {
4
+ /** Resolve authenticated, current trust. A failure must not return stale trust. */
5
+ resolve(signal?: AbortSignal): Promise<PortalDeployment>;
6
+ }
7
+ export declare function createStaticProvider(deployment: PortalDeployment): PortalProvider;
8
+ export interface DiscoveryProviderOptions {
9
+ readonly fetcher: (signal?: AbortSignal) => Promise<Uint8Array>;
10
+ readonly pinSHA256?: Uint8Array;
11
+ readonly manifestKeys?: ReadonlyMap<string, KeyObject>;
12
+ readonly requireSignature?: boolean;
13
+ /** Persist the required floor in deployment configuration across restarts. */
14
+ readonly minVersion?: bigint;
15
+ readonly expectedProfile?: string;
16
+ readonly now?: () => number;
17
+ }
18
+ export declare function createHTTPManifestFetcher(value: string, fetch?: typeof globalThis.fetch): DiscoveryProviderOptions["fetcher"];
19
+ export declare function createDiscoveryProvider(options: DiscoveryProviderOptions): PortalProvider;
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createStaticProvider = createStaticProvider;
4
+ exports.createHTTPManifestFetcher = createHTTPManifestFetcher;
5
+ exports.createDiscoveryProvider = createDiscoveryProvider;
6
+ const node_crypto_1 = require("node:crypto");
7
+ const deployment_js_1 = require("./deployment.js");
8
+ const qv2_js_1 = require("./qv2.js");
9
+ const strict_json_js_1 = require("./strict-json.js");
10
+ const relay_js_1 = require("./relay.js");
11
+ function createStaticProvider(deployment) {
12
+ const snapshot = JSON.parse(JSON.stringify(deployment));
13
+ (0, deployment_js_1.loadPortalDeployment)(snapshot);
14
+ return {
15
+ async resolve(signal) {
16
+ signal?.throwIfAborted();
17
+ return JSON.parse(JSON.stringify(snapshot));
18
+ },
19
+ };
20
+ }
21
+ const MANIFEST_DOMAIN = Buffer.from("NHP-QURL-V2-DISCOVERY-MANIFEST\0");
22
+ const HALF_ORDER = Buffer.from("7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8", "hex");
23
+ const LIMIT = 1_048_576;
24
+ const MAX_INT64 = (1n << 63n) - 1n;
25
+ function object(value, keys) {
26
+ if (!(0, strict_json_js_1.isStrictJsonObject)(value) || Object.keys(value).some((key) => !keys.includes(key)))
27
+ throw new Error("invalid discovery schema");
28
+ return value;
29
+ }
30
+ function decode(value) {
31
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/.test(value))
32
+ throw new Error("invalid discovery base64url");
33
+ const bytes = Buffer.from(value, "base64url");
34
+ if (bytes.toString("base64url") !== value)
35
+ throw new Error("noncanonical discovery base64url");
36
+ return bytes;
37
+ }
38
+ function createHTTPManifestFetcher(value, fetch = globalThis.fetch) {
39
+ const url = new URL(value);
40
+ if (url.protocol !== "https:" || url.username || url.password || url.hash)
41
+ throw new Error("discovery URL must be HTTPS without credentials or fragment");
42
+ return async (signal) => {
43
+ const deadline = AbortSignal.timeout(30_000);
44
+ const response = await fetch(url.href, {
45
+ redirect: "error",
46
+ signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
47
+ });
48
+ if (!response.ok) {
49
+ await response.body?.cancel().catch(() => undefined);
50
+ throw new Error(`discovery returned HTTP ${response.status}`);
51
+ }
52
+ return (0, relay_js_1.readBoundedBody)(response, LIMIT);
53
+ };
54
+ }
55
+ function createDiscoveryProvider(options) {
56
+ const pin = options.pinSHA256 === undefined ? undefined : Buffer.from(options.pinSHA256);
57
+ const keys = new Map(options.manifestKeys);
58
+ if (typeof options.fetcher !== "function" ||
59
+ (!pin && keys.size === 0) ||
60
+ (pin && pin.length !== 32) ||
61
+ (options.requireSignature && keys.size === 0))
62
+ throw new Error("discovery requires a fetcher and a valid pin or signing key");
63
+ for (const [kid, key] of keys) {
64
+ if (!kid.trim())
65
+ throw new Error("discovery signing key id is required");
66
+ (0, qv2_js_1.issuerKeyFromSpki)(key.export({ type: "spki", format: "der" }));
67
+ }
68
+ const fetcher = options.fetcher;
69
+ const required = options.requireSignature;
70
+ const profile = options.expectedProfile;
71
+ const now = options.now ?? Date.now;
72
+ let floor = options.minVersion ?? 0n;
73
+ if (typeof floor !== "bigint" || floor < 0n || floor > MAX_INT64)
74
+ throw new Error("invalid discovery version floor");
75
+ return {
76
+ async resolve(signal) {
77
+ signal?.throwIfAborted();
78
+ const envelope = object((0, strict_json_js_1.parseStrictJson)(await fetcher(signal), LIMIT), [
79
+ "manifest_b64",
80
+ "sig_b64",
81
+ "kid",
82
+ ]);
83
+ signal?.throwIfAborted();
84
+ const bytes = decode(envelope.manifest_b64);
85
+ if (pin && !(0, node_crypto_1.timingSafeEqual)(pin, (0, node_crypto_1.createHash)("sha256").update(bytes).digest()))
86
+ throw new Error("discovery manifest pin mismatch");
87
+ let signed = false;
88
+ if (keys.size && envelope.sig_b64 !== undefined && envelope.sig_b64 !== "") {
89
+ const signature = decode(envelope.sig_b64);
90
+ const key = typeof envelope.kid === "string" ? keys.get(envelope.kid) : undefined;
91
+ if (!key || signature.length !== 64)
92
+ throw new Error("invalid discovery signature");
93
+ if (Buffer.compare(signature.subarray(32), HALF_ORDER) > 0 ||
94
+ !(0, node_crypto_1.verify)("sha256", Buffer.concat([MANIFEST_DOMAIN, bytes]), { key, dsaEncoding: "ieee-p1363" }, signature))
95
+ throw new Error("invalid discovery signature");
96
+ signed = true;
97
+ }
98
+ if ((!pin && !signed) || (required && !signed))
99
+ throw new Error("discovery manifest is not authenticated");
100
+ const manifest = object((0, strict_json_js_1.parseStrictJson)(bytes, LIMIT), [
101
+ "profile",
102
+ "version",
103
+ "issued_at",
104
+ "not_after",
105
+ "issuers",
106
+ "relay_allowlist",
107
+ ]);
108
+ const { version, issued_at: issuedAt, not_after: notAfter } = manifest;
109
+ if (typeof version !== "bigint" ||
110
+ version <= 0n ||
111
+ version > MAX_INT64 ||
112
+ typeof issuedAt !== "bigint" ||
113
+ issuedAt <= 0n ||
114
+ issuedAt > MAX_INT64 ||
115
+ typeof notAfter !== "bigint" ||
116
+ notAfter <= issuedAt ||
117
+ notAfter > MAX_INT64 ||
118
+ (manifest.profile !== undefined && typeof manifest.profile !== "string") ||
119
+ (profile && profile !== manifest.profile))
120
+ throw new Error("invalid discovery manifest");
121
+ const seconds = BigInt(Math.floor(now() / 1000));
122
+ if (seconds + 120n < issuedAt)
123
+ throw new Error("discovery manifest is not yet valid");
124
+ if (seconds > notAfter)
125
+ throw new Error("discovery manifest expired");
126
+ if (!Array.isArray(manifest.issuers) ||
127
+ !Array.isArray(manifest.relay_allowlist) ||
128
+ !manifest.relay_allowlist.some((value) => typeof value === "string" && value.trim()))
129
+ throw new Error("discovery manifest has no usable trust");
130
+ const deployment = {
131
+ cells: [],
132
+ issuers: manifest.issuers.map((row) => {
133
+ const issuer = object(row, ["kid", "spki_der_b64"]);
134
+ if (typeof issuer.kid !== "string" || typeof issuer.spki_der_b64 !== "string")
135
+ throw new Error("invalid discovery issuer");
136
+ decode(issuer.spki_der_b64);
137
+ return { kid: issuer.kid, spki_der_b64: issuer.spki_der_b64 };
138
+ }),
139
+ relay_allowlist: manifest.relay_allowlist.map((value) => {
140
+ if (typeof value !== "string")
141
+ throw new Error("invalid discovery relay host");
142
+ return value;
143
+ }),
144
+ };
145
+ (0, deployment_js_1.loadPortalDeployment)(deployment);
146
+ // No await between comparison and advance: concurrent resolutions cannot lower the floor.
147
+ if (version < floor)
148
+ throw new Error("discovery manifest downgrade");
149
+ floor = version;
150
+ return deployment;
151
+ },
152
+ };
153
+ }
@@ -6,10 +6,11 @@ export interface VerifiedQv2Link {
6
6
  readonly kid: string;
7
7
  readonly cellPublicKey: Uint8Array;
8
8
  readonly resourcePublicKeyB64: string;
9
+ readonly relayUrl: string;
9
10
  };
10
11
  readonly devicePrivateKey: Uint8Array;
11
12
  }
12
- export declare function verifyQv2Link(qurl: string, issuers: ReadonlyMap<string, KeyObject>): VerifiedQv2Link;
13
+ export declare function verifyQv2Link(qurl: string, issuers: ReadonlyMap<string, KeyObject>, expectedCRID?: string): VerifiedQv2Link;
13
14
  export declare function issuerKeyFromSpki(spki: Uint8Array): KeyObject;
14
15
  export declare function x25519PublicFromPrivate(raw: Uint8Array): Uint8Array;
15
16
  export declare function strictBase64Url(value: string): Uint8Array;
@@ -18,6 +19,7 @@ type ParsedClaims = {
18
19
  kid: string;
19
20
  cellPublicKeyB64: string;
20
21
  resourcePublicKeyB64: string;
22
+ relayUrl: string;
21
23
  qurlUserPublicKeyB64: string;
22
24
  };
23
25
  declare function parseClaims(raw: Uint8Array): ParsedClaims;