@suveren/gateway 0.9.0 → 0.10.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 (221) hide show
  1. package/README.md +33 -1
  2. package/bin/suveren-gateway.js +203 -5
  3. package/content/integrations/erp.json +184 -0
  4. package/content/integrations/index.json +2 -1
  5. package/dist/control-plane/index.mjs +653 -179
  6. package/dist/mcp-server/http.mjs +466 -259
  7. package/dist/ui/assets/{index-CIiOCgjJ.js → index-B1qNl7zo.js} +1 -1
  8. package/dist/ui/index.html +1 -1
  9. package/lib/as-pairing.mjs +77 -0
  10. package/lib/config.mjs +48 -0
  11. package/node_modules/undici/LICENSE +21 -0
  12. package/node_modules/undici/README.md +741 -0
  13. package/node_modules/undici/docs/docs/api/Agent.md +84 -0
  14. package/node_modules/undici/docs/docs/api/BalancedPool.md +99 -0
  15. package/node_modules/undici/docs/docs/api/CacheStorage.md +30 -0
  16. package/node_modules/undici/docs/docs/api/CacheStore.md +164 -0
  17. package/node_modules/undici/docs/docs/api/Client.md +288 -0
  18. package/node_modules/undici/docs/docs/api/ClientStats.md +27 -0
  19. package/node_modules/undici/docs/docs/api/Connector.md +115 -0
  20. package/node_modules/undici/docs/docs/api/ContentType.md +57 -0
  21. package/node_modules/undici/docs/docs/api/Cookies.md +128 -0
  22. package/node_modules/undici/docs/docs/api/Debug.md +62 -0
  23. package/node_modules/undici/docs/docs/api/DiagnosticsChannel.md +317 -0
  24. package/node_modules/undici/docs/docs/api/Dispatcher.md +1393 -0
  25. package/node_modules/undici/docs/docs/api/EnvHttpProxyAgent.md +159 -0
  26. package/node_modules/undici/docs/docs/api/Errors.md +49 -0
  27. package/node_modules/undici/docs/docs/api/EventSource.md +45 -0
  28. package/node_modules/undici/docs/docs/api/Fetch.md +60 -0
  29. package/node_modules/undici/docs/docs/api/GlobalInstallation.md +139 -0
  30. package/node_modules/undici/docs/docs/api/H2CClient.md +263 -0
  31. package/node_modules/undici/docs/docs/api/MockAgent.md +603 -0
  32. package/node_modules/undici/docs/docs/api/MockCallHistory.md +197 -0
  33. package/node_modules/undici/docs/docs/api/MockCallHistoryLog.md +43 -0
  34. package/node_modules/undici/docs/docs/api/MockClient.md +81 -0
  35. package/node_modules/undici/docs/docs/api/MockErrors.md +12 -0
  36. package/node_modules/undici/docs/docs/api/MockPool.md +555 -0
  37. package/node_modules/undici/docs/docs/api/Pool.md +84 -0
  38. package/node_modules/undici/docs/docs/api/PoolStats.md +35 -0
  39. package/node_modules/undici/docs/docs/api/ProxyAgent.md +229 -0
  40. package/node_modules/undici/docs/docs/api/RedirectHandler.md +93 -0
  41. package/node_modules/undici/docs/docs/api/RetryAgent.md +50 -0
  42. package/node_modules/undici/docs/docs/api/RetryHandler.md +118 -0
  43. package/node_modules/undici/docs/docs/api/RoundRobinPool.md +145 -0
  44. package/node_modules/undici/docs/docs/api/SnapshotAgent.md +616 -0
  45. package/node_modules/undici/docs/docs/api/Socks5ProxyAgent.md +275 -0
  46. package/node_modules/undici/docs/docs/api/Util.md +25 -0
  47. package/node_modules/undici/docs/docs/api/WebSocket.md +141 -0
  48. package/node_modules/undici/docs/docs/api/api-lifecycle.md +91 -0
  49. package/node_modules/undici/docs/docs/best-practices/client-certificate.md +64 -0
  50. package/node_modules/undici/docs/docs/best-practices/crawling.md +58 -0
  51. package/node_modules/undici/docs/docs/best-practices/mocking-request.md +190 -0
  52. package/node_modules/undici/docs/docs/best-practices/proxy.md +127 -0
  53. package/node_modules/undici/docs/docs/best-practices/undici-vs-builtin-fetch.md +224 -0
  54. package/node_modules/undici/docs/docs/best-practices/writing-tests.md +63 -0
  55. package/node_modules/undici/index-fetch.js +65 -0
  56. package/node_modules/undici/index.d.ts +3 -0
  57. package/node_modules/undici/index.js +234 -0
  58. package/node_modules/undici/lib/api/abort-signal.js +59 -0
  59. package/node_modules/undici/lib/api/api-connect.js +110 -0
  60. package/node_modules/undici/lib/api/api-pipeline.js +252 -0
  61. package/node_modules/undici/lib/api/api-request.js +214 -0
  62. package/node_modules/undici/lib/api/api-stream.js +209 -0
  63. package/node_modules/undici/lib/api/api-upgrade.js +111 -0
  64. package/node_modules/undici/lib/api/index.js +7 -0
  65. package/node_modules/undici/lib/api/readable.js +580 -0
  66. package/node_modules/undici/lib/cache/memory-cache-store.js +279 -0
  67. package/node_modules/undici/lib/cache/sqlite-cache-store.js +467 -0
  68. package/node_modules/undici/lib/core/connect.js +153 -0
  69. package/node_modules/undici/lib/core/constants.js +143 -0
  70. package/node_modules/undici/lib/core/diagnostics.js +227 -0
  71. package/node_modules/undici/lib/core/errors.js +477 -0
  72. package/node_modules/undici/lib/core/request.js +485 -0
  73. package/node_modules/undici/lib/core/socks5-client.js +422 -0
  74. package/node_modules/undici/lib/core/socks5-utils.js +212 -0
  75. package/node_modules/undici/lib/core/symbols.js +75 -0
  76. package/node_modules/undici/lib/core/tree.js +160 -0
  77. package/node_modules/undici/lib/core/util.js +992 -0
  78. package/node_modules/undici/lib/dispatcher/agent.js +158 -0
  79. package/node_modules/undici/lib/dispatcher/balanced-pool.js +221 -0
  80. package/node_modules/undici/lib/dispatcher/client-h1.js +1746 -0
  81. package/node_modules/undici/lib/dispatcher/client-h2.js +1060 -0
  82. package/node_modules/undici/lib/dispatcher/client.js +668 -0
  83. package/node_modules/undici/lib/dispatcher/dispatcher-base.js +184 -0
  84. package/node_modules/undici/lib/dispatcher/dispatcher.js +48 -0
  85. package/node_modules/undici/lib/dispatcher/env-http-proxy-agent.js +146 -0
  86. package/node_modules/undici/lib/dispatcher/fixed-queue.js +135 -0
  87. package/node_modules/undici/lib/dispatcher/h2c-client.js +51 -0
  88. package/node_modules/undici/lib/dispatcher/pool-base.js +214 -0
  89. package/node_modules/undici/lib/dispatcher/pool.js +118 -0
  90. package/node_modules/undici/lib/dispatcher/proxy-agent.js +319 -0
  91. package/node_modules/undici/lib/dispatcher/retry-agent.js +35 -0
  92. package/node_modules/undici/lib/dispatcher/round-robin-pool.js +137 -0
  93. package/node_modules/undici/lib/dispatcher/socks5-proxy-agent.js +260 -0
  94. package/node_modules/undici/lib/encoding/index.js +33 -0
  95. package/node_modules/undici/lib/global.js +59 -0
  96. package/node_modules/undici/lib/handler/cache-handler.js +776 -0
  97. package/node_modules/undici/lib/handler/cache-revalidation-handler.js +124 -0
  98. package/node_modules/undici/lib/handler/decorator-handler.js +67 -0
  99. package/node_modules/undici/lib/handler/deduplication-handler.js +460 -0
  100. package/node_modules/undici/lib/handler/redirect-handler.js +238 -0
  101. package/node_modules/undici/lib/handler/retry-handler.js +504 -0
  102. package/node_modules/undici/lib/handler/unwrap-handler.js +106 -0
  103. package/node_modules/undici/lib/handler/wrap-handler.js +105 -0
  104. package/node_modules/undici/lib/interceptor/cache.js +621 -0
  105. package/node_modules/undici/lib/interceptor/decompress.js +551 -0
  106. package/node_modules/undici/lib/interceptor/deduplicate.js +117 -0
  107. package/node_modules/undici/lib/interceptor/dns.js +571 -0
  108. package/node_modules/undici/lib/interceptor/dump.js +99 -0
  109. package/node_modules/undici/lib/interceptor/redirect.js +21 -0
  110. package/node_modules/undici/lib/interceptor/response-error.js +95 -0
  111. package/node_modules/undici/lib/interceptor/retry.js +19 -0
  112. package/node_modules/undici/lib/llhttp/.gitkeep +0 -0
  113. package/node_modules/undici/lib/llhttp/constants.d.ts +195 -0
  114. package/node_modules/undici/lib/llhttp/constants.js +531 -0
  115. package/node_modules/undici/lib/llhttp/llhttp-wasm.js +15 -0
  116. package/node_modules/undici/lib/llhttp/llhttp_simd-wasm.js +15 -0
  117. package/node_modules/undici/lib/llhttp/utils.d.ts +2 -0
  118. package/node_modules/undici/lib/llhttp/utils.js +12 -0
  119. package/node_modules/undici/lib/mock/mock-agent.js +232 -0
  120. package/node_modules/undici/lib/mock/mock-call-history.js +248 -0
  121. package/node_modules/undici/lib/mock/mock-client.js +68 -0
  122. package/node_modules/undici/lib/mock/mock-errors.js +29 -0
  123. package/node_modules/undici/lib/mock/mock-interceptor.js +209 -0
  124. package/node_modules/undici/lib/mock/mock-pool.js +68 -0
  125. package/node_modules/undici/lib/mock/mock-symbols.js +32 -0
  126. package/node_modules/undici/lib/mock/mock-utils.js +486 -0
  127. package/node_modules/undici/lib/mock/pending-interceptors-formatter.js +43 -0
  128. package/node_modules/undici/lib/mock/snapshot-agent.js +353 -0
  129. package/node_modules/undici/lib/mock/snapshot-recorder.js +588 -0
  130. package/node_modules/undici/lib/mock/snapshot-utils.js +158 -0
  131. package/node_modules/undici/lib/util/cache.js +676 -0
  132. package/node_modules/undici/lib/util/date.js +670 -0
  133. package/node_modules/undici/lib/util/promise.js +28 -0
  134. package/node_modules/undici/lib/util/runtime-features.js +124 -0
  135. package/node_modules/undici/lib/util/stats.js +32 -0
  136. package/node_modules/undici/lib/util/timers.js +425 -0
  137. package/node_modules/undici/lib/web/cache/cache.js +864 -0
  138. package/node_modules/undici/lib/web/cache/cachestorage.js +152 -0
  139. package/node_modules/undici/lib/web/cache/util.js +45 -0
  140. package/node_modules/undici/lib/web/cookies/constants.js +12 -0
  141. package/node_modules/undici/lib/web/cookies/index.js +199 -0
  142. package/node_modules/undici/lib/web/cookies/parse.js +314 -0
  143. package/node_modules/undici/lib/web/cookies/util.js +352 -0
  144. package/node_modules/undici/lib/web/eventsource/eventsource-stream.js +494 -0
  145. package/node_modules/undici/lib/web/eventsource/eventsource.js +501 -0
  146. package/node_modules/undici/lib/web/eventsource/util.js +29 -0
  147. package/node_modules/undici/lib/web/fetch/LICENSE +21 -0
  148. package/node_modules/undici/lib/web/fetch/body.js +509 -0
  149. package/node_modules/undici/lib/web/fetch/constants.js +131 -0
  150. package/node_modules/undici/lib/web/fetch/data-url.js +596 -0
  151. package/node_modules/undici/lib/web/fetch/formdata-parser.js +586 -0
  152. package/node_modules/undici/lib/web/fetch/formdata.js +259 -0
  153. package/node_modules/undici/lib/web/fetch/global.js +40 -0
  154. package/node_modules/undici/lib/web/fetch/headers.js +719 -0
  155. package/node_modules/undici/lib/web/fetch/index.js +2413 -0
  156. package/node_modules/undici/lib/web/fetch/request.js +1115 -0
  157. package/node_modules/undici/lib/web/fetch/response.js +641 -0
  158. package/node_modules/undici/lib/web/fetch/util.js +1522 -0
  159. package/node_modules/undici/lib/web/infra/index.js +229 -0
  160. package/node_modules/undici/lib/web/subresource-integrity/Readme.md +9 -0
  161. package/node_modules/undici/lib/web/subresource-integrity/subresource-integrity.js +307 -0
  162. package/node_modules/undici/lib/web/webidl/index.js +1006 -0
  163. package/node_modules/undici/lib/web/websocket/connection.js +339 -0
  164. package/node_modules/undici/lib/web/websocket/constants.js +126 -0
  165. package/node_modules/undici/lib/web/websocket/events.js +331 -0
  166. package/node_modules/undici/lib/web/websocket/frame.js +133 -0
  167. package/node_modules/undici/lib/web/websocket/permessage-deflate.js +105 -0
  168. package/node_modules/undici/lib/web/websocket/receiver.js +507 -0
  169. package/node_modules/undici/lib/web/websocket/sender.js +109 -0
  170. package/node_modules/undici/lib/web/websocket/stream/websocketerror.js +104 -0
  171. package/node_modules/undici/lib/web/websocket/stream/websocketstream.js +496 -0
  172. package/node_modules/undici/lib/web/websocket/util.js +347 -0
  173. package/node_modules/undici/lib/web/websocket/websocket.js +758 -0
  174. package/node_modules/undici/package.json +152 -0
  175. package/node_modules/undici/scripts/strip-comments.js +10 -0
  176. package/node_modules/undici/types/README.md +6 -0
  177. package/node_modules/undici/types/agent.d.ts +32 -0
  178. package/node_modules/undici/types/api.d.ts +43 -0
  179. package/node_modules/undici/types/balanced-pool.d.ts +30 -0
  180. package/node_modules/undici/types/cache-interceptor.d.ts +179 -0
  181. package/node_modules/undici/types/cache.d.ts +36 -0
  182. package/node_modules/undici/types/client-stats.d.ts +15 -0
  183. package/node_modules/undici/types/client.d.ts +139 -0
  184. package/node_modules/undici/types/connector.d.ts +36 -0
  185. package/node_modules/undici/types/content-type.d.ts +21 -0
  186. package/node_modules/undici/types/cookies.d.ts +30 -0
  187. package/node_modules/undici/types/diagnostics-channel.d.ts +74 -0
  188. package/node_modules/undici/types/dispatcher.d.ts +275 -0
  189. package/node_modules/undici/types/env-http-proxy-agent.d.ts +22 -0
  190. package/node_modules/undici/types/errors.d.ts +177 -0
  191. package/node_modules/undici/types/eventsource.d.ts +66 -0
  192. package/node_modules/undici/types/fetch.d.ts +231 -0
  193. package/node_modules/undici/types/formdata.d.ts +114 -0
  194. package/node_modules/undici/types/global-dispatcher.d.ts +9 -0
  195. package/node_modules/undici/types/global-origin.d.ts +7 -0
  196. package/node_modules/undici/types/h2c-client.d.ts +73 -0
  197. package/node_modules/undici/types/handlers.d.ts +14 -0
  198. package/node_modules/undici/types/header.d.ts +160 -0
  199. package/node_modules/undici/types/index.d.ts +91 -0
  200. package/node_modules/undici/types/interceptors.d.ts +82 -0
  201. package/node_modules/undici/types/mock-agent.d.ts +68 -0
  202. package/node_modules/undici/types/mock-call-history.d.ts +111 -0
  203. package/node_modules/undici/types/mock-client.d.ts +27 -0
  204. package/node_modules/undici/types/mock-errors.d.ts +12 -0
  205. package/node_modules/undici/types/mock-interceptor.d.ts +94 -0
  206. package/node_modules/undici/types/mock-pool.d.ts +27 -0
  207. package/node_modules/undici/types/patch.d.ts +29 -0
  208. package/node_modules/undici/types/pool-stats.d.ts +19 -0
  209. package/node_modules/undici/types/pool.d.ts +41 -0
  210. package/node_modules/undici/types/proxy-agent.d.ts +29 -0
  211. package/node_modules/undici/types/readable.d.ts +68 -0
  212. package/node_modules/undici/types/retry-agent.d.ts +8 -0
  213. package/node_modules/undici/types/retry-handler.d.ts +125 -0
  214. package/node_modules/undici/types/round-robin-pool.d.ts +41 -0
  215. package/node_modules/undici/types/snapshot-agent.d.ts +109 -0
  216. package/node_modules/undici/types/socks5-proxy-agent.d.ts +25 -0
  217. package/node_modules/undici/types/util.d.ts +18 -0
  218. package/node_modules/undici/types/utility.d.ts +7 -0
  219. package/node_modules/undici/types/webidl.d.ts +347 -0
  220. package/node_modules/undici/types/websocket.d.ts +188 -0
  221. package/package.json +4 -2
@@ -4,8 +4,8 @@ import {
4
4
  } from "./chunk-3BNWN6VP.mjs";
5
5
 
6
6
  // src/index.ts
7
- import { randomBytes as randomBytes2 } from "crypto";
8
- import { existsSync as existsSync9, readFileSync as readFileSync10, renameSync as renameSync2 } from "fs";
7
+ import { randomBytes as randomBytes3 } from "crypto";
8
+ import { existsSync as existsSync9, readFileSync as readFileSync11, renameSync as renameSync2 } from "fs";
9
9
  import { dirname as dirname8, join as join11 } from "path";
10
10
  import { homedir as homedir7 } from "os";
11
11
  import express from "express";
@@ -117,6 +117,13 @@ var Vault = class {
117
117
  this.clearKey();
118
118
  this.lockedReason = "as-key-mismatch";
119
119
  }
120
+ /** Lock because a pin-tls-enforced connection to the Authority Server
121
+ * presented a certificate whose public key doesn't match the one pinned
122
+ * at pairing — see as-tls-pin.ts. Mirrors lockAsKeyMismatch() exactly. */
123
+ lockAsTlsMismatch() {
124
+ this.clearKey();
125
+ this.lockedReason = "as-tls-mismatch";
126
+ }
120
127
  /**
121
128
  * Boot-time only: record that the gateway starts locked because the
122
129
  * resolved Authority Server URL differs from the one it was last paired
@@ -673,18 +680,38 @@ function readPairing(dataDir) {
673
680
  try {
674
681
  const data = JSON.parse(readFileSync3(path, "utf-8"));
675
682
  if (typeof data.asUrl !== "string" || typeof data.publicKeyHex !== "string") return null;
676
- return { asUrl: data.asUrl, publicKeyHex: data.publicKeyHex, pairedAt: data.pairedAt ?? "" };
683
+ return {
684
+ asUrl: data.asUrl,
685
+ publicKeyHex: data.publicKeyHex,
686
+ pairedAt: data.pairedAt ?? "",
687
+ ...typeof data.tlsSpkiPinHex === "string" ? { tlsSpkiPinHex: data.tlsSpkiPinHex } : {}
688
+ };
677
689
  } catch {
678
690
  return null;
679
691
  }
680
692
  }
681
- function writePairing(dataDir, asUrl, publicKeyHex) {
682
- const pairing = { asUrl, publicKeyHex, pairedAt: (/* @__PURE__ */ new Date()).toISOString() };
693
+ function writePairing(dataDir, asUrl, publicKeyHex, opts = {}) {
694
+ const pairing = {
695
+ asUrl,
696
+ publicKeyHex,
697
+ pairedAt: (/* @__PURE__ */ new Date()).toISOString(),
698
+ ...opts.tlsSpkiPinHex ? { tlsSpkiPinHex: opts.tlsSpkiPinHex } : {}
699
+ };
683
700
  const path = pairingPath(dataDir);
684
701
  mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
685
702
  writeFileSync2(path, JSON.stringify(pairing, null, 2), { encoding: "utf-8", mode: 384 });
686
703
  return pairing;
687
704
  }
705
+ function recordTlsPin(dataDir, asUrl, tlsSpkiPinHex) {
706
+ const existing = readPairing(dataDir);
707
+ if (!existing || existing.asUrl !== asUrl) return;
708
+ const path = pairingPath(dataDir);
709
+ writeFileSync2(
710
+ path,
711
+ JSON.stringify({ ...existing, tlsSpkiPinHex }, null, 2),
712
+ { encoding: "utf-8", mode: 384 }
713
+ );
714
+ }
688
715
  function clearPairing(dataDir) {
689
716
  const path = pairingPath(dataDir);
690
717
  try {
@@ -698,46 +725,450 @@ function fingerprintOf(publicKeyHex) {
698
725
  return digest.match(/.{1,4}/g)?.join(":") ?? digest;
699
726
  }
700
727
 
728
+ // src/lib/as-challenge.ts
729
+ import { createPublicKey, randomBytes as randomBytes2, verify as cryptoVerify } from "crypto";
730
+ import { canonicalize } from "@hap/core";
731
+
732
+ // src/lib/as-tls-pin.ts
733
+ import { createHash as createHash3 } from "crypto";
734
+ import { checkServerIdentity as defaultCheckServerIdentity, rootCertificates } from "tls";
735
+ import { Agent as HttpsAgent } from "https";
736
+ import { readFileSync as readFileSync4 } from "fs";
737
+ import { Agent, fetch as undiciFetch } from "undici";
738
+ function effectiveCa() {
739
+ const path = process.env.NODE_EXTRA_CA_CERTS;
740
+ if (!path) return void 0;
741
+ try {
742
+ return [...rootCertificates, readFileSync4(path, "utf-8")];
743
+ } catch {
744
+ return void 0;
745
+ }
746
+ }
747
+ var PIN_CHECK_ERROR_CODE = "EAS_TLS_PIN_CHECK";
748
+ function taggedCheckError(err) {
749
+ err.code = PIN_CHECK_ERROR_CODE;
750
+ return err;
751
+ }
752
+ function isPinCheckError(err) {
753
+ let cur = err;
754
+ for (let i = 0; i < 5 && cur; i++) {
755
+ if (cur instanceof Error && cur.code === PIN_CHECK_ERROR_CODE) return true;
756
+ cur = cur instanceof Error ? cur.cause : void 0;
757
+ }
758
+ return false;
759
+ }
760
+ var AsTlsMismatchError = class extends Error {
761
+ constructor(message) {
762
+ super(message);
763
+ this.name = "AsTlsMismatchError";
764
+ }
765
+ };
766
+ function spkiSha256Hex(cert) {
767
+ if (!cert.pubkey) {
768
+ throw new Error("peer certificate carries no pubkey (expected the detailed certificate object)");
769
+ }
770
+ return createHash3("sha256").update(cert.pubkey).digest("hex");
771
+ }
772
+ function formatPinFingerprint(hex) {
773
+ const upper = hex.toUpperCase();
774
+ return upper.match(/.{1,4}/g)?.join(":") ?? upper;
775
+ }
776
+ function pinnedCheckServerIdentity(pinnedSpkiHex) {
777
+ const wantHex = pinnedSpkiHex.toLowerCase();
778
+ return (hostname, cert) => {
779
+ const defaultErr = defaultCheckServerIdentity(hostname, cert);
780
+ if (defaultErr) return taggedCheckError(defaultErr);
781
+ let actualHex;
782
+ try {
783
+ actualHex = spkiSha256Hex(cert);
784
+ } catch (err) {
785
+ return taggedCheckError(new Error(
786
+ `could not read the server certificate's public key to check the TLS pin \u2014 ${err instanceof Error ? err.message : String(err)}`
787
+ ));
788
+ }
789
+ if (actualHex !== wantHex) {
790
+ return taggedCheckError(new Error(
791
+ `TLS certificate pin mismatch for ${hostname}: expected SPKI sha256 ${formatPinFingerprint(wantHex)}, got ${formatPinFingerprint(actualHex)}.`
792
+ ));
793
+ }
794
+ return void 0;
795
+ };
796
+ }
797
+ function buildPinnedDispatcher(pinnedSpkiHex) {
798
+ return new Agent({
799
+ connect: {
800
+ ca: effectiveCa(),
801
+ checkServerIdentity: pinnedCheckServerIdentity(pinnedSpkiHex)
802
+ }
803
+ });
804
+ }
805
+ function buildCapturingDispatcher() {
806
+ let captured;
807
+ const dispatcher = new Agent({
808
+ connect: {
809
+ ca: effectiveCa(),
810
+ checkServerIdentity: (hostname, cert) => {
811
+ captured = cert;
812
+ const defaultErr = defaultCheckServerIdentity(hostname, cert);
813
+ return defaultErr ? taggedCheckError(defaultErr) : void 0;
814
+ }
815
+ }
816
+ });
817
+ return { dispatcher, getCapturedCert: () => captured };
818
+ }
819
+ function buildPinnedHttpsAgent(getPinning) {
820
+ return new HttpsAgent({
821
+ checkServerIdentity: (hostname, cert) => {
822
+ const pinning = getPinning();
823
+ if (!pinning.enforce) return defaultCheckServerIdentity(hostname, cert);
824
+ if (!pinning.pinnedSpkiHex) {
825
+ return taggedCheckError(new Error(
826
+ `pin-tls is on but no certificate is pinned yet for ${hostname} \u2014 sign in again to establish it (re-pairing required).`
827
+ ));
828
+ }
829
+ return pinnedCheckServerIdentity(pinning.pinnedSpkiHex)(hostname, cert);
830
+ },
831
+ ca: effectiveCa()
832
+ });
833
+ }
834
+ async function fetchAs(url, init, pinning) {
835
+ if (!pinning.enforce && !pinning.capture) {
836
+ const res2 = await fetch(url, init);
837
+ return { res: res2 };
838
+ }
839
+ if (pinning.enforce && !pinning.capture && !pinning.pinnedSpkiHex) {
840
+ throw new AsTlsMismatchError(
841
+ `pin-tls is on but no certificate is pinned yet for ${url} \u2014 sign in again to establish it (re-pairing required).`
842
+ );
843
+ }
844
+ if (pinning.pinnedSpkiHex && (pinning.enforce || !pinning.capture)) {
845
+ const dispatcher2 = buildPinnedDispatcher(pinning.pinnedSpkiHex);
846
+ try {
847
+ const res2 = await undiciFetch(url, { ...init, dispatcher: dispatcher2 });
848
+ return { res: res2 };
849
+ } catch (err) {
850
+ if (!isPinCheckError(err)) throw err;
851
+ throw new AsTlsMismatchError(
852
+ `TLS pin check failed for ${url} \u2014 ${describeFetchError(err)}. Refusing the connection; the API key / session cookie were never sent. If the Authority Server's certificate changed intentionally (a new key, not just renewal), an operator must clear the pairing and sign in again to re-pin it.`
853
+ );
854
+ }
855
+ }
856
+ const { dispatcher, getCapturedCert } = buildCapturingDispatcher();
857
+ let res;
858
+ try {
859
+ res = await undiciFetch(url, { ...init, dispatcher });
860
+ } catch (err) {
861
+ if (!isPinCheckError(err)) throw err;
862
+ throw new AsTlsMismatchError(
863
+ `could not verify the Authority Server at ${url} while establishing its TLS pin \u2014 ${describeFetchError(err)}.`
864
+ );
865
+ }
866
+ const cert = getCapturedCert();
867
+ return { res, capturedSpkiHex: cert ? spkiSha256Hex(cert) : void 0 };
868
+ }
869
+ function describeFetchError(err) {
870
+ if (err instanceof Error) {
871
+ const cause = err.cause;
872
+ if (cause instanceof Error) return cause.message;
873
+ return err.message;
874
+ }
875
+ return String(err);
876
+ }
877
+
878
+ // src/lib/as-challenge.ts
879
+ var AsChallengeUnreachableError = class extends Error {
880
+ constructor(message) {
881
+ super(message);
882
+ this.name = "AsChallengeUnreachableError";
883
+ }
884
+ };
885
+ var AsChallengeInvalidError = class extends Error {
886
+ constructor(message) {
887
+ super(message);
888
+ this.name = "AsChallengeInvalidError";
889
+ }
890
+ };
891
+ var CHALLENGE_MAX_SKEW_SECONDS = 300;
892
+ var CHALLENGE_TYPE = "hap-as-challenge";
893
+ var CHALLENGE_DOMAIN_PREFIX = "hap-as-challenge\0";
894
+ function challengeSigningBytes(unsigned) {
895
+ return Buffer.concat([
896
+ Buffer.from(CHALLENGE_DOMAIN_PREFIX, "utf-8"),
897
+ Buffer.from(canonicalize({ typ: unsigned.typ, nonce: unsigned.nonce, issuedAt: unsigned.issuedAt }), "utf-8")
898
+ ]);
899
+ }
900
+ function publicKeyFromHex(publicKeyHex) {
901
+ const raw = Buffer.from(publicKeyHex, "hex");
902
+ return createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") }, format: "jwk" });
903
+ }
904
+ function verifyChallengeSignature(publicKeyHex, unsigned, signature) {
905
+ try {
906
+ const base64 = signature.replace(/-/g, "+").replace(/_/g, "/");
907
+ const padding = base64.length % 4 === 0 ? "" : "=".repeat(4 - base64.length % 4);
908
+ const sigBytes = Buffer.from(base64 + padding, "base64");
909
+ const key = publicKeyFromHex(publicKeyHex);
910
+ return cryptoVerify(null, challengeSigningBytes(unsigned), key, sigBytes);
911
+ } catch {
912
+ return false;
913
+ }
914
+ }
915
+ async function verifyAsHoldsKey(asUrl, publicKeyHex, pinning = { enforce: false, capture: true }) {
916
+ const nonce = randomBytes2(32).toString("base64url");
917
+ let res;
918
+ let capturedSpkiHex;
919
+ try {
920
+ const result = await fetchAs(
921
+ `${asUrl}/api/as/challenge`,
922
+ {
923
+ method: "POST",
924
+ headers: { "Content-Type": "application/json" },
925
+ body: JSON.stringify({ nonce }),
926
+ signal: AbortSignal.timeout(5e3)
927
+ },
928
+ pinning
929
+ );
930
+ res = result.res;
931
+ capturedSpkiHex = result.capturedSpkiHex;
932
+ } catch (err) {
933
+ if (err instanceof AsTlsMismatchError) throw err;
934
+ throw new AsChallengeUnreachableError(
935
+ `could not reach the Authority Server at ${asUrl} to verify it holds its signing key \u2014 ${err instanceof Error ? err.message : String(err)}`
936
+ );
937
+ }
938
+ if (!res.ok) {
939
+ throw new AsChallengeUnreachableError(
940
+ `the Authority Server at ${asUrl} answered its signing-key challenge with HTTP ${res.status}`
941
+ );
942
+ }
943
+ const body = await res.json().catch(() => null);
944
+ if (!body) {
945
+ throw new AsChallengeUnreachableError(
946
+ `the Authority Server at ${asUrl} returned a malformed response to its signing-key challenge`
947
+ );
948
+ }
949
+ if (body.typ !== CHALLENGE_TYPE) {
950
+ throw new AsChallengeInvalidError(
951
+ `the challenge response carries the wrong type ("${String(body.typ)}", expected "${CHALLENGE_TYPE}")`
952
+ );
953
+ }
954
+ if (body.nonce !== nonce) {
955
+ throw new AsChallengeInvalidError(
956
+ "the challenge response echoes a different nonce than the one sent"
957
+ );
958
+ }
959
+ if (typeof body.issuedAt !== "number" || !Number.isFinite(body.issuedAt)) {
960
+ throw new AsChallengeInvalidError("the challenge response carries no valid issuedAt");
961
+ }
962
+ const skew = Math.abs(Math.floor(Date.now() / 1e3) - body.issuedAt);
963
+ if (skew > CHALLENGE_MAX_SKEW_SECONDS) {
964
+ throw new AsChallengeInvalidError(
965
+ `the challenge response is timestamped ${skew}s away from now, beyond the ${CHALLENGE_MAX_SKEW_SECONDS}s tolerance`
966
+ );
967
+ }
968
+ if (typeof body.signature !== "string" || !body.signature) {
969
+ throw new AsChallengeInvalidError("the challenge response carries no signature");
970
+ }
971
+ if (!verifyChallengeSignature(publicKeyHex, { typ: body.typ, nonce: body.nonce, issuedAt: body.issuedAt }, body.signature)) {
972
+ throw new AsChallengeInvalidError(
973
+ "the challenge signature does not verify under the key being checked \u2014 this is exactly what the challenge exists to catch: something answered the request but does not hold the matching private key."
974
+ );
975
+ }
976
+ return { capturedTlsSpkiHex: capturedSpkiHex };
977
+ }
978
+
979
+ // src/lib/as-config.ts
980
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
981
+ import { dirname as dirname3, join as join4 } from "path";
982
+ var DEFAULT_AS_URL = "https://www.suveren.ai";
983
+ function configPath(dataDir) {
984
+ return join4(dataDir, "config.json");
985
+ }
986
+ function readAsConfigStrict(dataDir) {
987
+ const path = configPath(dataDir);
988
+ if (!existsSync3(path)) return {};
989
+ let data;
990
+ try {
991
+ data = JSON.parse(readFileSync5(path, "utf-8"));
992
+ } catch (err) {
993
+ throw new Error(
994
+ `Could not parse ${path}: ${err instanceof Error ? err.message : String(err)}`
995
+ );
996
+ }
997
+ const out = {};
998
+ if (typeof data.asUrl === "string") out.asUrl = data.asUrl;
999
+ if (typeof data.caFile === "string") out.caFile = data.caFile;
1000
+ if (typeof data.pinTls === "boolean") out.pinTls = data.pinTls;
1001
+ if (typeof data.pinTlsExpectedFingerprint === "string") out.pinTlsExpectedFingerprint = data.pinTlsExpectedFingerprint;
1002
+ return out;
1003
+ }
1004
+ function readAsConfig(dataDir) {
1005
+ const path = configPath(dataDir);
1006
+ if (!existsSync3(path)) return {};
1007
+ try {
1008
+ const data = JSON.parse(readFileSync5(path, "utf-8"));
1009
+ const out = {};
1010
+ if (typeof data.asUrl === "string") out.asUrl = data.asUrl;
1011
+ if (typeof data.caFile === "string") out.caFile = data.caFile;
1012
+ if (typeof data.pinTls === "boolean") out.pinTls = data.pinTls;
1013
+ if (typeof data.pinTlsExpectedFingerprint === "string") out.pinTlsExpectedFingerprint = data.pinTlsExpectedFingerprint;
1014
+ return out;
1015
+ } catch {
1016
+ return {};
1017
+ }
1018
+ }
1019
+ function writeAsConfig(dataDir, patch) {
1020
+ const merged = { ...readAsConfig(dataDir), ...patch };
1021
+ const path = configPath(dataDir);
1022
+ mkdirSync3(dirname3(path), { recursive: true, mode: 448 });
1023
+ writeFileSync3(path, JSON.stringify(merged, null, 2), { encoding: "utf-8", mode: 384 });
1024
+ return merged;
1025
+ }
1026
+ function validateAsUrl(candidate) {
1027
+ const trimmed = candidate.trim();
1028
+ if (!trimmed) return { ok: false, error: "The Authority Server URL is empty." };
1029
+ let parsed;
1030
+ try {
1031
+ parsed = new URL(trimmed);
1032
+ } catch {
1033
+ return { ok: false, error: `"${trimmed}" is not a valid URL.` };
1034
+ }
1035
+ const isLocalHttp = parsed.protocol === "http:" && (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1");
1036
+ if (parsed.protocol !== "https:" && !isLocalHttp) {
1037
+ return {
1038
+ ok: false,
1039
+ error: `"${trimmed}" must use https:// (http:// is only allowed for http://localhost or http://127.0.0.1).`
1040
+ };
1041
+ }
1042
+ let normalized = `${parsed.origin}${parsed.pathname}${parsed.search}${parsed.hash}`;
1043
+ if (normalized.endsWith("/") && normalized !== `${parsed.origin}/`) {
1044
+ normalized = normalized.slice(0, -1);
1045
+ } else if (normalized === `${parsed.origin}/`) {
1046
+ normalized = parsed.origin;
1047
+ }
1048
+ return { ok: true, url: normalized };
1049
+ }
1050
+ function resolveAsUrl(dataDir, flag) {
1051
+ if (flag) {
1052
+ const v = validateAsUrl(flag);
1053
+ if (!v.ok) throw new Error(`Invalid --as-url: ${v.error}`);
1054
+ return v.url;
1055
+ }
1056
+ const envUrl = process.env.SUVEREN_AS_URL;
1057
+ if (envUrl) {
1058
+ const v = validateAsUrl(envUrl);
1059
+ if (!v.ok) throw new Error(`Invalid SUVEREN_AS_URL: ${v.error}`);
1060
+ return v.url;
1061
+ }
1062
+ const saved = readAsConfigStrict(dataDir).asUrl;
1063
+ if (saved) {
1064
+ const v = validateAsUrl(saved);
1065
+ if (!v.ok) throw new Error(`Invalid saved as-url "${saved}" in ${dataDir}/config.json: ${v.error}`);
1066
+ return v.url;
1067
+ }
1068
+ return DEFAULT_AS_URL;
1069
+ }
1070
+ function resolvePinTls(dataDir) {
1071
+ return readAsConfig(dataDir).pinTls === true;
1072
+ }
1073
+ function validatePinTlsForUrl(pinTls, asUrl) {
1074
+ if (!pinTls) return;
1075
+ if (!asUrl.startsWith("https:")) {
1076
+ throw new Error(
1077
+ `config.json sets "pinTls": true, but the Authority Server URL (${asUrl}) is not https:// \u2014 there is no TLS certificate to pin. Fix the saved as-url, or turn pin-tls off: \`suveren-gateway config set pin-tls off\`.`
1078
+ );
1079
+ }
1080
+ }
1081
+ function resolvePinTlsExpectedFingerprint(dataDir) {
1082
+ return readAsConfig(dataDir).pinTlsExpectedFingerprint;
1083
+ }
1084
+ function clearPinTlsExpectedFingerprint(dataDir) {
1085
+ if (readAsConfig(dataDir).pinTlsExpectedFingerprint === void 0) return;
1086
+ writeAsConfig(dataDir, { pinTlsExpectedFingerprint: void 0 });
1087
+ }
1088
+
701
1089
  // src/routes/auth.ts
702
1090
  var DEFAULT_SP_URL = process.env.SUVEREN_AS_URL ?? "https://www.suveren.ai";
703
- async function checkAsKeyBeforeLogin(asUrl, dataDir) {
704
- let publicKeyHex;
1091
+ async function fetchAsPublicKey(asUrl) {
705
1092
  try {
706
1093
  const res = await fetch(`${asUrl}/api/as/pubkey`, { signal: AbortSignal.timeout(5e3) });
707
1094
  if (!res.ok) {
708
1095
  return {
709
1096
  ok: false,
710
- error: "as_unreachable",
711
- message: `Could not reach the Authority Server at ${asUrl} to verify its signing key (HTTP ${res.status}). Refusing to sign in.`
1097
+ message: `Could not reach the Authority Server at ${asUrl} to read its signing key (HTTP ${res.status}). Refusing to sign in.`
712
1098
  };
713
1099
  }
714
1100
  const body = await res.json().catch(() => null);
715
1101
  if (!body || typeof body.publicKey !== "string" || !body.publicKey) {
716
1102
  return {
717
1103
  ok: false,
718
- error: "as_unreachable",
719
1104
  message: `The Authority Server at ${asUrl} returned a malformed signing key. Refusing to sign in.`
720
1105
  };
721
1106
  }
722
- publicKeyHex = body.publicKey;
1107
+ return { ok: true, publicKeyHex: body.publicKey };
723
1108
  } catch (err) {
724
1109
  return {
725
1110
  ok: false,
726
- error: "as_unreachable",
727
- message: `Could not reach the Authority Server at ${asUrl} to verify its signing key \u2014 ${err instanceof Error ? err.message : String(err)}. Refusing to sign in.`
1111
+ message: `Could not reach the Authority Server at ${asUrl} to read its signing key \u2014 ${err instanceof Error ? err.message : String(err)}. Refusing to sign in.`
728
1112
  };
729
1113
  }
1114
+ }
1115
+ async function checkAsKeyBeforeLogin(asUrl, dataDir) {
730
1116
  const existing = readPairing(dataDir);
731
- if (existing && existing.asUrl === asUrl && existing.publicKeyHex !== publicKeyHex) {
732
- return {
733
- ok: false,
734
- error: "as_key_mismatch",
735
- message: `The Authority Server at ${asUrl} presented a signing key that does not match the one pinned when this gateway last signed in (pinned fingerprint ${fingerprintOf(existing.publicKeyHex)}, now seeing ${fingerprintOf(publicKeyHex)}). Refusing to sign in. If the AS's key changed intentionally, an operator must clear the old pairing before signing in again.`
736
- };
1117
+ const pinnedKey = existing && existing.asUrl === asUrl ? existing.publicKeyHex : void 0;
1118
+ const existingTlsPin = (existing && existing.asUrl === asUrl ? existing.tlsSpkiPinHex : void 0) ?? resolvePinTlsExpectedFingerprint(dataDir);
1119
+ let candidateKey;
1120
+ if (pinnedKey) {
1121
+ candidateKey = pinnedKey;
1122
+ } else {
1123
+ const fetched = await fetchAsPublicKey(asUrl);
1124
+ if (!fetched.ok) {
1125
+ return { ok: false, error: "as_unreachable", message: fetched.message };
1126
+ }
1127
+ candidateKey = fetched.publicKeyHex;
737
1128
  }
738
- return { ok: true, publicKeyHex };
1129
+ const pinning = {
1130
+ enforce: resolvePinTls(dataDir),
1131
+ pinnedSpkiHex: existingTlsPin,
1132
+ capture: true
1133
+ };
1134
+ let capturedTlsSpkiHex;
1135
+ try {
1136
+ const result = await verifyAsHoldsKey(asUrl, candidateKey, pinning);
1137
+ capturedTlsSpkiHex = result.capturedTlsSpkiHex;
1138
+ } catch (err) {
1139
+ if (err instanceof AsTlsMismatchError) {
1140
+ return { ok: false, error: "as_tls_mismatch", message: err.message };
1141
+ }
1142
+ if (err instanceof AsChallengeUnreachableError) {
1143
+ return { ok: false, error: "as_unverified", message: `${err.message}. Refusing to sign in.` };
1144
+ }
1145
+ if (err instanceof AsChallengeInvalidError) {
1146
+ if (pinnedKey) {
1147
+ return {
1148
+ ok: false,
1149
+ error: "as_key_mismatch",
1150
+ message: `The Authority Server at ${asUrl} does not match the one pinned when this gateway last signed in (pinned fingerprint ${fingerprintOf(pinnedKey)}) \u2014 its signing-key challenge did not verify (${err.message}). Refusing to sign in. If the AS's key changed intentionally, an operator must clear the old pairing before signing in again.`
1151
+ };
1152
+ }
1153
+ return {
1154
+ ok: false,
1155
+ error: "as_unverified",
1156
+ message: `The Authority Server at ${asUrl} could not prove it holds the signing key it presented \u2014 ${err.message}. Refusing to sign in.`
1157
+ };
1158
+ }
1159
+ throw err;
1160
+ }
1161
+ return {
1162
+ ok: true,
1163
+ publicKeyHex: candidateKey,
1164
+ // A fresh capture (always present for https, unless pin-tls is on and
1165
+ // an existing pin was enforced instead — see fetchAs) wins; otherwise
1166
+ // whatever was already on file carries through unchanged.
1167
+ tlsSpkiPinHex: capturedTlsSpkiHex ?? existingTlsPin
1168
+ };
739
1169
  }
740
- function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablished, pairing) {
1170
+ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablished, pairing, lockAsTlsMismatch2 = () => {
1171
+ }) {
741
1172
  const router = Router();
742
1173
  const SP_URL2 = pairing?.asUrl ?? DEFAULT_SP_URL;
743
1174
  router.post("/login", loginRateLimit2, async (req, res) => {
@@ -752,24 +1183,43 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
752
1183
  if (pairing) {
753
1184
  keyCheck = await checkAsKeyBeforeLogin(pairing.asUrl, pairing.dataDir);
754
1185
  if (!keyCheck.ok) {
755
- res.status(keyCheck.error === "as_key_mismatch" ? 409 : 502).json({
1186
+ const status = keyCheck.error === "as_key_mismatch" || keyCheck.error === "as_tls_mismatch" ? 409 : 502;
1187
+ res.status(status).json({
756
1188
  error: keyCheck.error,
757
1189
  message: keyCheck.message
758
1190
  });
759
1191
  return;
760
1192
  }
761
1193
  }
762
- const spRes = await fetch(`${SP_URL2}/api/auth/session`, {
763
- method: "POST",
764
- headers: {
765
- "Content-Type": "application/json",
766
- "X-API-Key": apiKey,
767
- // The AS grants a 30-day gateway session ONLY to a login that
768
- // identifies itself this way; without it, a 24-hour browser
769
- // session (see doc at the top of this file).
770
- ...clientVersionHeaders()
1194
+ const sessionPinning = {
1195
+ enforce: Boolean(pairing && resolvePinTls(pairing.dataDir)),
1196
+ pinnedSpkiHex: keyCheck?.tlsSpkiPinHex
1197
+ };
1198
+ let spRes;
1199
+ try {
1200
+ const result = await fetchAs(
1201
+ `${SP_URL2}/api/auth/session`,
1202
+ {
1203
+ method: "POST",
1204
+ headers: {
1205
+ "Content-Type": "application/json",
1206
+ "X-API-Key": apiKey,
1207
+ // The AS grants a 30-day gateway session ONLY to a login that
1208
+ // identifies itself this way; without it, a 24-hour browser
1209
+ // session (see doc at the top of this file).
1210
+ ...clientVersionHeaders()
1211
+ }
1212
+ },
1213
+ sessionPinning
1214
+ );
1215
+ spRes = result.res;
1216
+ } catch (err) {
1217
+ if (err instanceof AsTlsMismatchError) {
1218
+ res.status(409).json({ error: "as_tls_mismatch", message: err.message });
1219
+ return;
771
1220
  }
772
- });
1221
+ throw err;
1222
+ }
773
1223
  if (!spRes.ok) {
774
1224
  const err = await spRes.json().catch(() => ({ error: "Invalid API key" }));
775
1225
  res.status(spRes.status).json(err);
@@ -778,9 +1228,15 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
778
1228
  if (pairing && keyCheck?.publicKeyHex) {
779
1229
  const existing = readPairing(pairing.dataDir);
780
1230
  if (!existing || existing.asUrl !== pairing.asUrl) {
781
- writePairing(pairing.dataDir, pairing.asUrl, keyCheck.publicKeyHex);
1231
+ writePairing(pairing.dataDir, pairing.asUrl, keyCheck.publicKeyHex, {
1232
+ tlsSpkiPinHex: keyCheck.tlsSpkiPinHex
1233
+ });
782
1234
  console.error(`[Control Plane] Paired with Authority Server ${pairing.asUrl} (fingerprint ${fingerprintOf(keyCheck.publicKeyHex)})`);
1235
+ } else if (keyCheck.tlsSpkiPinHex && keyCheck.tlsSpkiPinHex !== existing.tlsSpkiPinHex) {
1236
+ recordTlsPin(pairing.dataDir, pairing.asUrl, keyCheck.tlsSpkiPinHex);
1237
+ console.error(`[Control Plane] TLS pin for Authority Server ${pairing.asUrl} captured/updated.`);
783
1238
  }
1239
+ clearPinTlsExpectedFingerprint(pairing.dataDir);
784
1240
  }
785
1241
  const data = await spRes.json();
786
1242
  const setCookieHeaders = spRes.headers.getSetCookie?.() ?? [];
@@ -828,10 +1284,11 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
828
1284
  const kp = await loadOrGenerateKeyPair(vault2);
829
1285
  const localPubkeyB64 = Buffer.from(kp.publicKey).toString("base64");
830
1286
  const spCookie = vault2.getSpCookie();
831
- const meKeyRes = await fetch(`${SP_URL2}/api/users/me/pubkey`, {
832
- headers: spCookie ? { Cookie: spCookie } : {},
833
- signal: AbortSignal.timeout(5e3)
834
- });
1287
+ const { res: meKeyRes } = await fetchAs(
1288
+ `${SP_URL2}/api/users/me/pubkey`,
1289
+ { headers: spCookie ? { Cookie: spCookie } : {}, signal: AbortSignal.timeout(5e3) },
1290
+ sessionPinning
1291
+ );
835
1292
  let needsUpdate = false;
836
1293
  if (meKeyRes.status === 404) {
837
1294
  needsUpdate = true;
@@ -840,15 +1297,19 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
840
1297
  needsUpdate = meKeyData.pubkey !== localPubkeyB64;
841
1298
  }
842
1299
  if (needsUpdate) {
843
- const putRes = await fetch(`${SP_URL2}/api/users/me/pubkey`, {
844
- method: "PUT",
845
- headers: {
846
- "Content-Type": "application/json",
847
- ...spCookie ? { Cookie: spCookie } : {}
1300
+ const { res: putRes } = await fetchAs(
1301
+ `${SP_URL2}/api/users/me/pubkey`,
1302
+ {
1303
+ method: "PUT",
1304
+ headers: {
1305
+ "Content-Type": "application/json",
1306
+ ...spCookie ? { Cookie: spCookie } : {}
1307
+ },
1308
+ body: JSON.stringify({ pubkey: localPubkeyB64 }),
1309
+ signal: AbortSignal.timeout(5e3)
848
1310
  },
849
- body: JSON.stringify({ pubkey: localPubkeyB64 }),
850
- signal: AbortSignal.timeout(5e3)
851
- });
1311
+ sessionPinning
1312
+ );
852
1313
  if (!putRes.ok) {
853
1314
  console.error(`[Control Plane] E2EE pubkey registration failed: ${putRes.status}`);
854
1315
  } else {
@@ -858,7 +1319,12 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
858
1319
  console.error("[Control Plane] E2EE pubkey already up to date");
859
1320
  }
860
1321
  } catch (err) {
861
- console.error("[Control Plane] E2EE pubkey auto-register failed:", err);
1322
+ if (err instanceof AsTlsMismatchError) {
1323
+ console.error("[Control Plane] E2EE pubkey auto-register aborted \u2014 TLS pin mismatch:", err.message);
1324
+ lockAsTlsMismatch2();
1325
+ } else {
1326
+ console.error("[Control Plane] E2EE pubkey auto-register failed:", err);
1327
+ }
862
1328
  }
863
1329
  for (const credId of vault2.listCredentials()) {
864
1330
  try {
@@ -895,12 +1361,20 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
895
1361
  router.post("/logout", logoutAuth, async (_req, res) => {
896
1362
  const cookie = vault2.getSpCookie();
897
1363
  vault2.clearKey();
1364
+ const logoutPinning = pairing ? { enforce: resolvePinTls(pairing.dataDir), pinnedSpkiHex: readPairing(pairing.dataDir)?.tlsSpkiPinHex } : { enforce: false };
898
1365
  await Promise.allSettled([
899
1366
  unconfigureSession(),
900
- cookie ? fetch(`${SP_URL2}/api/auth/logout`, {
901
- method: "POST",
902
- headers: { cookie, ...clientVersionHeaders() },
903
- redirect: "manual"
1367
+ cookie ? fetchAs(
1368
+ `${SP_URL2}/api/auth/logout`,
1369
+ { method: "POST", headers: { cookie, ...clientVersionHeaders() }, redirect: "manual" },
1370
+ logoutPinning
1371
+ ).catch((err) => {
1372
+ if (err instanceof AsTlsMismatchError) {
1373
+ console.error("[Control Plane] Logout: TLS pin mismatch talking to the Authority Server \u2014 local sign-out still applied:", err.message);
1374
+ lockAsTlsMismatch2();
1375
+ } else {
1376
+ throw err;
1377
+ }
904
1378
  }) : Promise.resolve()
905
1379
  ]);
906
1380
  res.json({ ok: true });
@@ -1512,18 +1986,18 @@ async function testAIConnectivity(config) {
1512
1986
  }
1513
1987
 
1514
1988
  // src/lib/agent-brief-store.ts
1515
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
1989
+ import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
1516
1990
  import { homedir as homedir2 } from "os";
1517
- import { join as join4 } from "path";
1991
+ import { join as join5 } from "path";
1518
1992
  var AGENT_CONTEXT_MAX_BYTES = 16 * 1024;
1519
1993
  function agentBriefPath() {
1520
- const dataDir = process.env.SUVEREN_DATA_DIR ?? join4(homedir2(), ".suveren");
1521
- return join4(dataDir, "context.md");
1994
+ const dataDir = process.env.SUVEREN_DATA_DIR ?? join5(homedir2(), ".suveren");
1995
+ return join5(dataDir, "context.md");
1522
1996
  }
1523
1997
  function readAgentBrief() {
1524
1998
  const filePath = agentBriefPath();
1525
- if (!existsSync3(filePath)) return "";
1526
- return readFileSync4(filePath, "utf-8");
1999
+ if (!existsSync4(filePath)) return "";
2000
+ return readFileSync6(filePath, "utf-8");
1527
2001
  }
1528
2002
 
1529
2003
  // src/routes/ai.ts
@@ -1887,7 +2361,7 @@ function createMCPRouter() {
1887
2361
  // src/routes/autostart.ts
1888
2362
  import { Router as Router6 } from "express";
1889
2363
  import { execFile } from "child_process";
1890
- import { existsSync as existsSync4 } from "fs";
2364
+ import { existsSync as existsSync5 } from "fs";
1891
2365
  import { dirname as pathDirname, resolve } from "path";
1892
2366
  import { fileURLToPath } from "url";
1893
2367
  import { promisify } from "util";
@@ -1908,7 +2382,7 @@ function findCli(dirname9 = MODULE_DIR) {
1908
2382
  // resolves differently between a shell and a login service — and it would
1909
2383
  // mask a genuinely missing CLI whenever the cwd happened to be the repo.
1910
2384
  ];
1911
- return candidates.find(existsSync4) ?? null;
2385
+ return candidates.find(existsSync5) ?? null;
1912
2386
  }
1913
2387
  async function cli(args) {
1914
2388
  const bin = findCli();
@@ -1989,17 +2463,17 @@ function createAutostartRouter() {
1989
2463
  import { spawn, spawnSync } from "child_process";
1990
2464
 
1991
2465
  // src/lib/notifier-icon.ts
1992
- import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, existsSync as existsSync5 } from "fs";
2466
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync6 } from "fs";
1993
2467
  import { homedir as homedir3 } from "os";
1994
- import { join as join5, dirname as dirname3 } from "path";
2468
+ import { join as join6, dirname as dirname4 } from "path";
1995
2469
  var ICON_BASE64 = "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";
1996
2470
  function ensureNotifierIcon(dataDir) {
1997
2471
  try {
1998
- const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join5(homedir3(), ".suveren");
1999
- const path = join5(dir, "notifier-icon.png");
2000
- if (!existsSync5(path)) {
2001
- mkdirSync3(dirname3(path), { recursive: true });
2002
- writeFileSync3(path, Buffer.from(ICON_BASE64, "base64"));
2472
+ const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join6(homedir3(), ".suveren");
2473
+ const path = join6(dir, "notifier-icon.png");
2474
+ if (!existsSync6(path)) {
2475
+ mkdirSync4(dirname4(path), { recursive: true });
2476
+ writeFileSync4(path, Buffer.from(ICON_BASE64, "base64"));
2003
2477
  }
2004
2478
  return path;
2005
2479
  } catch {
@@ -2201,24 +2675,24 @@ import { Router as Router9 } from "express";
2201
2675
 
2202
2676
  // src/routes/approved-intents.ts
2203
2677
  import { Router as Router8 } from "express";
2204
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, existsSync as existsSync6, mkdirSync as mkdirSync4 } from "fs";
2678
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync5, existsSync as existsSync7, mkdirSync as mkdirSync5 } from "fs";
2205
2679
  import { homedir as homedir4 } from "os";
2206
- import { join as join6 } from "path";
2207
- var SUVEREN_DATA_DIR = process.env.SUVEREN_DATA_DIR ?? join6(homedir4(), ".suveren");
2208
- var FILE_PATH = join6(SUVEREN_DATA_DIR, "approved-intents.enc.json");
2680
+ import { join as join7 } from "path";
2681
+ var SUVEREN_DATA_DIR = process.env.SUVEREN_DATA_DIR ?? join7(homedir4(), ".suveren");
2682
+ var FILE_PATH = join7(SUVEREN_DATA_DIR, "approved-intents.enc.json");
2209
2683
  function readFile() {
2210
- if (!existsSync6(FILE_PATH)) {
2684
+ if (!existsSync7(FILE_PATH)) {
2211
2685
  return { version: 1, entries: {} };
2212
2686
  }
2213
2687
  try {
2214
- return JSON.parse(readFileSync5(FILE_PATH, "utf-8"));
2688
+ return JSON.parse(readFileSync7(FILE_PATH, "utf-8"));
2215
2689
  } catch {
2216
2690
  return { version: 1, entries: {} };
2217
2691
  }
2218
2692
  }
2219
2693
  function writeFile(data) {
2220
- mkdirSync4(SUVEREN_DATA_DIR, { recursive: true });
2221
- writeFileSync4(FILE_PATH, JSON.stringify(data, null, 2), "utf-8");
2694
+ mkdirSync5(SUVEREN_DATA_DIR, { recursive: true });
2695
+ writeFileSync5(FILE_PATH, JSON.stringify(data, null, 2), "utf-8");
2222
2696
  }
2223
2697
  function readApprovedIntents(vault2) {
2224
2698
  const file = readFile();
@@ -2267,7 +2741,7 @@ function createApprovedIntentsRouter(vault2) {
2267
2741
 
2268
2742
  // src/routes/evidence-export.ts
2269
2743
  var AS_FETCH_TIMEOUT_MS = 2e4;
2270
- function createEvidenceExportRouter(spUrl, vault2) {
2744
+ function createEvidenceExportRouter(spUrl, vault2, dataDir, lockAsTlsMismatch2) {
2271
2745
  const router = Router9();
2272
2746
  const getSpCookie = () => vault2.getSpCookie();
2273
2747
  router.get("/", async (_req, res) => {
@@ -2287,17 +2761,29 @@ function createEvidenceExportRouter(spUrl, vault2) {
2287
2761
  const controller = new AbortController();
2288
2762
  const timeout = setTimeout(() => controller.abort(), AS_FETCH_TIMEOUT_MS);
2289
2763
  try {
2290
- const asRes = await fetch(`${spUrl}/api/receipts/export`, {
2291
- headers: { Cookie: cookie },
2292
- signal: controller.signal
2293
- });
2764
+ const pin = readPairing(dataDir);
2765
+ const { res: asRes } = await fetchAs(
2766
+ `${spUrl}/api/receipts/export`,
2767
+ { headers: { Cookie: cookie }, signal: controller.signal },
2768
+ {
2769
+ enforce: resolvePinTls(dataDir),
2770
+ pinnedSpkiHex: pin && pin.asUrl === spUrl ? pin.tlsSpkiPinHex : void 0
2771
+ // Never capture here — only the sign-in challenge establishes or
2772
+ // refreshes a pin (as-challenge.ts).
2773
+ }
2774
+ );
2294
2775
  if (asRes.ok) {
2295
2776
  authorityServer = await asRes.json();
2296
2777
  } else {
2297
2778
  asError = `Authority Server export failed: ${asRes.status}`;
2298
2779
  }
2299
2780
  } catch (err) {
2300
- asError = `Authority Server unreachable: ${err instanceof Error ? err.message : String(err)}`;
2781
+ if (err instanceof AsTlsMismatchError) {
2782
+ lockAsTlsMismatch2();
2783
+ asError = err.message;
2784
+ } else {
2785
+ asError = `Authority Server unreachable: ${err instanceof Error ? err.message : String(err)}`;
2786
+ }
2301
2787
  } finally {
2302
2788
  clearTimeout(timeout);
2303
2789
  }
@@ -2483,7 +2969,7 @@ function createDecryptIntentRouter(vault2) {
2483
2969
 
2484
2970
  // src/lib/update-checker.ts
2485
2971
  import { execSync } from "child_process";
2486
- import { dirname as dirname4 } from "path";
2972
+ import { dirname as dirname5 } from "path";
2487
2973
  import { fileURLToPath as fileURLToPath2 } from "url";
2488
2974
  var GHCR_IMAGE = "suverenai/suveren-gateway";
2489
2975
  var NPM_PACKAGE = "@suveren/gateway";
@@ -2549,7 +3035,7 @@ function compareSemver(a, b) {
2549
3035
  return 0;
2550
3036
  }
2551
3037
  function checkDev() {
2552
- const cwd = dirname4(fileURLToPath2(import.meta.url));
3038
+ const cwd = dirname5(fileURLToPath2(import.meta.url));
2553
3039
  try {
2554
3040
  execSync("git fetch origin --quiet", { cwd, stdio: "ignore", timeout: 15e3 });
2555
3041
  const out = execSync("git rev-list HEAD..origin/main --count", {
@@ -2622,19 +3108,19 @@ data: ${data}
2622
3108
  import { Router as Router12 } from "express";
2623
3109
 
2624
3110
  // src/lib/gateway-settings.ts
2625
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, mkdirSync as mkdirSync5 } from "fs";
3111
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync6, mkdirSync as mkdirSync6 } from "fs";
2626
3112
  import { homedir as homedir5 } from "os";
2627
- import { join as join7, dirname as dirname5 } from "path";
3113
+ import { join as join8, dirname as dirname6 } from "path";
2628
3114
  var DEFAULT_SETTINGS = {
2629
3115
  desktopNotifications: true
2630
3116
  };
2631
3117
  function settingsPath(dataDir) {
2632
- const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join7(homedir5(), ".suveren");
2633
- return join7(dir, "settings.json");
3118
+ const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join8(homedir5(), ".suveren");
3119
+ return join8(dir, "settings.json");
2634
3120
  }
2635
3121
  function readSettings(dataDir) {
2636
3122
  try {
2637
- const raw = JSON.parse(readFileSync6(settingsPath(dataDir), "utf8"));
3123
+ const raw = JSON.parse(readFileSync8(settingsPath(dataDir), "utf8"));
2638
3124
  return {
2639
3125
  desktopNotifications: typeof raw.desktopNotifications === "boolean" ? raw.desktopNotifications : DEFAULT_SETTINGS.desktopNotifications
2640
3126
  };
@@ -2648,8 +3134,8 @@ function writeSettings(patch, dataDir) {
2648
3134
  ...typeof patch.desktopNotifications === "boolean" ? { desktopNotifications: patch.desktopNotifications } : {}
2649
3135
  };
2650
3136
  const path = settingsPath(dataDir);
2651
- mkdirSync5(dirname5(path), { recursive: true });
2652
- writeFileSync5(path, JSON.stringify(next, null, 2) + "\n", "utf8");
3137
+ mkdirSync6(dirname6(path), { recursive: true });
3138
+ writeFileSync6(path, JSON.stringify(next, null, 2) + "\n", "utf8");
2653
3139
  return next;
2654
3140
  }
2655
3141
 
@@ -2681,20 +3167,20 @@ function createGatewaySettingsRouter() {
2681
3167
  import { Router as Router13 } from "express";
2682
3168
 
2683
3169
  // src/lib/archived-mandates-store.ts
2684
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, mkdirSync as mkdirSync6, renameSync } from "fs";
3170
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync7, mkdirSync as mkdirSync7, renameSync } from "fs";
2685
3171
  import { homedir as homedir6 } from "os";
2686
- import { join as join8, dirname as dirname6 } from "path";
3172
+ import { join as join9, dirname as dirname7 } from "path";
2687
3173
  var ID_PATTERN = /^[A-Za-z0-9_.:-]{1,200}$/;
2688
3174
  function isValidMandateId(id) {
2689
3175
  return typeof id === "string" && ID_PATTERN.test(id);
2690
3176
  }
2691
3177
  function storePath(dataDir) {
2692
- const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join8(homedir6(), ".suveren");
2693
- return join8(dir, "archived-mandates.json");
3178
+ const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join9(homedir6(), ".suveren");
3179
+ return join9(dir, "archived-mandates.json");
2694
3180
  }
2695
3181
  function readArchived(dataDir) {
2696
3182
  try {
2697
- const raw = JSON.parse(readFileSync7(storePath(dataDir), "utf8"));
3183
+ const raw = JSON.parse(readFileSync9(storePath(dataDir), "utf8"));
2698
3184
  if (!Array.isArray(raw.archived)) return [];
2699
3185
  return [...new Set(raw.archived.filter(isValidMandateId))];
2700
3186
  } catch {
@@ -2704,9 +3190,9 @@ function readArchived(dataDir) {
2704
3190
  function writeArchived(ids, dataDir) {
2705
3191
  const next = [...new Set(ids.filter(isValidMandateId))].sort();
2706
3192
  const path = storePath(dataDir);
2707
- mkdirSync6(dirname6(path), { recursive: true });
3193
+ mkdirSync7(dirname7(path), { recursive: true });
2708
3194
  const tmp = `${path}.tmp`;
2709
- writeFileSync6(tmp, JSON.stringify({ archived: next }, null, 2) + "\n", "utf8");
3195
+ writeFileSync7(tmp, JSON.stringify({ archived: next }, null, 2) + "\n", "utf8");
2710
3196
  renameSync(tmp, path);
2711
3197
  return next;
2712
3198
  }
@@ -2748,6 +3234,7 @@ import { timingSafeEqual as timingSafeEqual2 } from "crypto";
2748
3234
  var ALLOWED = ["proposal-added", "action-approval-needed"];
2749
3235
  var SESSION_EXPIRED = "session-expired";
2750
3236
  var AS_KEY_MISMATCH = "as-key-mismatch";
3237
+ var AS_TLS_MISMATCH = "as-tls-mismatch";
2751
3238
  function secretMatches(provided, expected) {
2752
3239
  if (!expected) return false;
2753
3240
  if (!provided) return false;
@@ -2756,7 +3243,7 @@ function secretMatches(provided, expected) {
2756
3243
  if (a.length !== b.length) return false;
2757
3244
  return timingSafeEqual2(a, b);
2758
3245
  }
2759
- function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch) {
3246
+ function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch, onAsTlsMismatch) {
2760
3247
  const router = Router14();
2761
3248
  router.post("/event", (req, res) => {
2762
3249
  if (!secretMatches(req.headers["x-internal-secret"], getSecret())) {
@@ -2774,6 +3261,11 @@ function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch
2774
3261
  res.json({ ok: true });
2775
3262
  return;
2776
3263
  }
3264
+ if (type === AS_TLS_MISMATCH) {
3265
+ onAsTlsMismatch?.();
3266
+ res.json({ ok: true });
3267
+ return;
3268
+ }
2777
3269
  if (typeof type !== "string" || !ALLOWED.includes(type)) {
2778
3270
  res.status(400).json({ error: "Unsupported event type" });
2779
3271
  return;
@@ -2873,6 +3365,7 @@ function startNotificationDispatcher(opts = {}) {
2873
3365
  // src/lib/session-lock.ts
2874
3366
  var SESSION_LOCKED_MESSAGE = "Your sign-in ended after 30 days or was revoked. Sign in again.";
2875
3367
  var AS_KEY_MISMATCH_MESSAGE = "The Authority Server presented a signing key that does not match the one pinned at pairing. Sign in again to review.";
3368
+ var AS_TLS_MISMATCH_MESSAGE = "A connection to the Authority Server presented a TLS certificate that does not match the one pinned at pairing. The connection was refused before anything was sent. If the certificate changed intentionally (a new key, not just renewal), clear the pairing and sign in again to re-pin it.";
2876
3369
  function createSessionLock(deps) {
2877
3370
  const { vault: vault2, port: port2 } = deps;
2878
3371
  const notifyFn = deps.notifyFn ?? notify;
@@ -2907,6 +3400,23 @@ function createAsKeyMismatchLock(deps) {
2907
3400
  notifyFn(title, AS_KEY_MISMATCH_MESSAGE, process.platform, url);
2908
3401
  };
2909
3402
  }
3403
+ function createAsTlsMismatchLock(deps) {
3404
+ const { vault: vault2, port: port2 } = deps;
3405
+ const notifyFn = deps.notifyFn ?? notify;
3406
+ const unconfigureSessionFn = deps.unconfigureSessionFn ?? unconfigureSession;
3407
+ const emit = deps.emit ?? eventBus.emit.bind(eventBus);
3408
+ return function lockOnAsTlsMismatch() {
3409
+ if (!vault2.isUnlocked()) return;
3410
+ vault2.lockAsTlsMismatch();
3411
+ console.error("[Control Plane] Authority Server TLS pin mismatch \u2014 gateway LOCKED");
3412
+ void unconfigureSessionFn("as-tls-mismatch").catch((err) => {
3413
+ console.error("[Control Plane] Failed to push cleared session to MCP:", err);
3414
+ });
3415
+ emit("session-locked", { reason: "as-tls-mismatch", message: AS_TLS_MISMATCH_MESSAGE });
3416
+ const { title, url } = sessionExpiredNotification(port2);
3417
+ notifyFn(title, AS_TLS_MISMATCH_MESSAGE, process.platform, url);
3418
+ };
3419
+ }
2910
3420
 
2911
3421
  // src/lib/session-expiry-scheduler.ts
2912
3422
  var MAX_TIMEOUT_MS = 2147483647;
@@ -2976,17 +3486,17 @@ function buildSessionHealth(vault2) {
2976
3486
  }
2977
3487
 
2978
3488
  // src/lib/denials-reader.ts
2979
- import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
2980
- import { join as join9 } from "path";
3489
+ import { existsSync as existsSync8, readFileSync as readFileSync10 } from "fs";
3490
+ import { join as join10 } from "path";
2981
3491
  function loadDenials(dataDir, decrypt) {
2982
- const encPath = join9(dataDir, "denials.enc.json");
2983
- const plainPath = join9(dataDir, "denials.json");
2984
- if (existsSync7(encPath)) {
2985
- const { blob } = JSON.parse(readFileSync8(encPath, "utf-8"));
3492
+ const encPath = join10(dataDir, "denials.enc.json");
3493
+ const plainPath = join10(dataDir, "denials.json");
3494
+ if (existsSync8(encPath)) {
3495
+ const { blob } = JSON.parse(readFileSync10(encPath, "utf-8"));
2986
3496
  return JSON.parse(decrypt(blob)).records ?? [];
2987
3497
  }
2988
- if (existsSync7(plainPath)) {
2989
- return JSON.parse(readFileSync8(plainPath, "utf-8")).records ?? [];
3498
+ if (existsSync8(plainPath)) {
3499
+ return JSON.parse(readFileSync10(plainPath, "utf-8")).records ?? [];
2990
3500
  }
2991
3501
  return [];
2992
3502
  }
@@ -3000,74 +3510,6 @@ function selectDenials(records, opts = {}) {
3000
3510
  return { count, records: out };
3001
3511
  }
3002
3512
 
3003
- // src/lib/as-config.ts
3004
- import { existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync7 } from "fs";
3005
- import { dirname as dirname7, join as join10 } from "path";
3006
- var DEFAULT_AS_URL = "https://www.suveren.ai";
3007
- function configPath(dataDir) {
3008
- return join10(dataDir, "config.json");
3009
- }
3010
- function readAsConfigStrict(dataDir) {
3011
- const path = configPath(dataDir);
3012
- if (!existsSync8(path)) return {};
3013
- let data;
3014
- try {
3015
- data = JSON.parse(readFileSync9(path, "utf-8"));
3016
- } catch (err) {
3017
- throw new Error(
3018
- `Could not parse ${path}: ${err instanceof Error ? err.message : String(err)}`
3019
- );
3020
- }
3021
- const out = {};
3022
- if (typeof data.asUrl === "string") out.asUrl = data.asUrl;
3023
- if (typeof data.caFile === "string") out.caFile = data.caFile;
3024
- return out;
3025
- }
3026
- function validateAsUrl(candidate) {
3027
- const trimmed = candidate.trim();
3028
- if (!trimmed) return { ok: false, error: "The Authority Server URL is empty." };
3029
- let parsed;
3030
- try {
3031
- parsed = new URL(trimmed);
3032
- } catch {
3033
- return { ok: false, error: `"${trimmed}" is not a valid URL.` };
3034
- }
3035
- const isLocalHttp = parsed.protocol === "http:" && (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1");
3036
- if (parsed.protocol !== "https:" && !isLocalHttp) {
3037
- return {
3038
- ok: false,
3039
- error: `"${trimmed}" must use https:// (http:// is only allowed for http://localhost or http://127.0.0.1).`
3040
- };
3041
- }
3042
- let normalized = `${parsed.origin}${parsed.pathname}${parsed.search}${parsed.hash}`;
3043
- if (normalized.endsWith("/") && normalized !== `${parsed.origin}/`) {
3044
- normalized = normalized.slice(0, -1);
3045
- } else if (normalized === `${parsed.origin}/`) {
3046
- normalized = parsed.origin;
3047
- }
3048
- return { ok: true, url: normalized };
3049
- }
3050
- function resolveAsUrl(dataDir, flag) {
3051
- if (flag) {
3052
- const v = validateAsUrl(flag);
3053
- if (!v.ok) throw new Error(`Invalid --as-url: ${v.error}`);
3054
- return v.url;
3055
- }
3056
- const envUrl = process.env.SUVEREN_AS_URL;
3057
- if (envUrl) {
3058
- const v = validateAsUrl(envUrl);
3059
- if (!v.ok) throw new Error(`Invalid SUVEREN_AS_URL: ${v.error}`);
3060
- return v.url;
3061
- }
3062
- const saved = readAsConfigStrict(dataDir).asUrl;
3063
- if (saved) {
3064
- const v = validateAsUrl(saved);
3065
- if (!v.ok) throw new Error(`Invalid saved as-url "${saved}" in ${dataDir}/config.json: ${v.error}`);
3066
- return v.url;
3067
- }
3068
- return DEFAULT_AS_URL;
3069
- }
3070
-
3071
3513
  // src/index.ts
3072
3514
  (function migrateDataDir() {
3073
3515
  if (process.env.SUVEREN_DATA_DIR) return;
@@ -3085,6 +3527,7 @@ function resolveAsUrl(dataDir, flag) {
3085
3527
  })();
3086
3528
  var DATA_DIR = process.env.SUVEREN_DATA_DIR ?? join11(homedir7(), ".suveren");
3087
3529
  var SP_URL = resolveAsUrl(DATA_DIR);
3530
+ validatePinTlsForUrl(resolvePinTls(DATA_DIR), SP_URL);
3088
3531
  var port = parseInt(process.env.SUVEREN_CP_PORT ?? "3402", 10);
3089
3532
  var HAP_MODE = process.env.HAP_MODE ?? "personal";
3090
3533
  var UI_DIST = process.env.HAP_UI_DIST ?? join11(import.meta.dirname ?? __dirname, "../../ui/dist");
@@ -3099,7 +3542,7 @@ var vault = new Vault(DATA_DIR);
3099
3542
  vault.markBootLockedAsUrlChanged();
3100
3543
  }
3101
3544
  })();
3102
- var internalSecret2 = process.env.SUVEREN_INTERNAL_SECRET ?? randomBytes2(32).toString("hex");
3545
+ var internalSecret2 = process.env.SUVEREN_INTERNAL_SECRET ?? randomBytes3(32).toString("hex");
3103
3546
  setInternalSecret(internalSecret2);
3104
3547
  var app = express();
3105
3548
  var jsonParser = express.json();
@@ -3123,6 +3566,7 @@ function loginRateLimit(req, res, next) {
3123
3566
  }
3124
3567
  var lockExpiredSession = createSessionLock({ vault, port });
3125
3568
  var lockAsKeyMismatch = createAsKeyMismatchLock({ vault, port });
3569
+ var lockAsTlsMismatch = createAsTlsMismatchLock({ vault, port });
3126
3570
  var sessionExpiryScheduler = new SessionExpiryScheduler(
3127
3571
  () => vault.getSessionExpiresAt(),
3128
3572
  lockExpiredSession
@@ -3339,7 +3783,7 @@ app.get("/auth/oauth/:integrationId/health", authGuard, async (req, res) => {
3339
3783
  }
3340
3784
  });
3341
3785
  app.get("/events", requireAllowedHost, requireAuthQueryOrHeader(vault), createEventsHandler());
3342
- app.use("/internal", jsonParser, createInternalEventsRouter(() => internalSecret2, lockExpiredSession, lockAsKeyMismatch));
3786
+ app.use("/internal", jsonParser, createInternalEventsRouter(() => internalSecret2, lockExpiredSession, lockAsKeyMismatch, lockAsTlsMismatch));
3343
3787
  app.use("/vault", jsonParser, authGuard, createVaultRouter(vault));
3344
3788
  app.use("/ai", jsonParser, authGuard, createAIRouter(vault));
3345
3789
  app.use("/ai-prompts", jsonParser, authGuard, createAIPromptsRouter());
@@ -3348,7 +3792,7 @@ app.use("/autostart", jsonParser, authGuard, createAutostartRouter());
3348
3792
  app.use("/api/gateway-settings", jsonParser, authGuard, createGatewaySettingsRouter());
3349
3793
  app.use("/api/archived-mandates", jsonParser, authGuard, createArchivedMandatesRouter());
3350
3794
  app.use("/api/encrypt-intent", jsonParser, authGuard, createEncryptIntentRouter());
3351
- app.use("/api/evidence-export", authGuard, createEvidenceExportRouter(SP_URL, vault));
3795
+ app.use("/api/evidence-export", authGuard, createEvidenceExportRouter(SP_URL, vault, DATA_DIR, lockAsTlsMismatch));
3352
3796
  app.use("/api/evidence", authGuard, createEvidenceRouter());
3353
3797
  app.use("/api/decrypt-intent", jsonParser, authGuard, createDecryptIntentRouter(vault));
3354
3798
  app.use("/api/approved-intents", jsonParser, authGuard, createApprovedIntentsRouter(vault));
@@ -3489,11 +3933,18 @@ app.get("/skipped-commitments", authGuard, async (_req, res) => {
3489
3933
  });
3490
3934
  app.get("/as-pairing", authGuard, (_req, res) => {
3491
3935
  const pairing = readPairing(DATA_DIR);
3936
+ const paired = pairing !== null && pairing.asUrl === SP_URL;
3492
3937
  res.json({
3493
3938
  asUrl: SP_URL,
3494
- paired: pairing !== null && pairing.asUrl === SP_URL,
3495
- fingerprint: pairing && pairing.asUrl === SP_URL ? fingerprintOf(pairing.publicKeyHex) : null,
3496
- pairedAt: pairing && pairing.asUrl === SP_URL ? pairing.pairedAt : null
3939
+ paired,
3940
+ fingerprint: paired ? fingerprintOf(pairing.publicKeyHex) : null,
3941
+ pairedAt: paired ? pairing.pairedAt : null,
3942
+ // Opt-in TLS pinning (`config set pin-tls on`) — see as-tls-pin.ts.
3943
+ // `tlsPinFingerprint` uses the SAME display convention as the signing
3944
+ // key (formatPinFingerprint mirrors fingerprintOf's grouping), applied
3945
+ // to the already-computed SPKI digest rather than re-hashing a key.
3946
+ pinTlsEnabled: resolvePinTls(DATA_DIR),
3947
+ tlsPinFingerprint: paired && pairing.tlsSpkiPinHex ? formatPinFingerprint(pairing.tlsSpkiPinHex) : null
3497
3948
  });
3498
3949
  });
3499
3950
  app.get("/denials", authGuard, (req, res) => {
@@ -3562,11 +4013,19 @@ app.put("/agent-brief/context", jsonParser, authGuard, async (req, res) => {
3562
4013
  }
3563
4014
  });
3564
4015
  app.use("/api", authGuard);
4016
+ var pinnedProxyAgent = SP_URL.startsWith("https:") ? buildPinnedHttpsAgent(() => {
4017
+ const pin = readPairing(DATA_DIR);
4018
+ return {
4019
+ enforce: resolvePinTls(DATA_DIR),
4020
+ pinnedSpkiHex: pin && pin.asUrl === SP_URL ? pin.tlsSpkiPinHex : void 0
4021
+ };
4022
+ }) : void 0;
3565
4023
  app.use(
3566
4024
  createProxyMiddleware({
3567
4025
  target: SP_URL,
3568
4026
  changeOrigin: true,
3569
4027
  pathFilter: "/api",
4028
+ ...pinnedProxyAgent ? { agent: pinnedProxyAgent } : {},
3570
4029
  on: {
3571
4030
  proxyReq: (proxyReq) => {
3572
4031
  const cookie = vault.getSpCookie();
@@ -3574,6 +4033,21 @@ app.use(
3574
4033
  proxyReq.setHeader("Cookie", cookie);
3575
4034
  }
3576
4035
  },
4036
+ // A pin check failure means the connection never completed its TLS
4037
+ // handshake — nothing (not even this call's own cookie) reached the
4038
+ // network. Lock the gateway with the real reason, loudly; an ordinary
4039
+ // network error (AS outage) is NOT a pin failure (see isPinCheckError
4040
+ // / as-tls-pin.ts's module doc comment on outage misclassification)
4041
+ // and falls through to http-proxy-middleware's default error handling.
4042
+ error: (err, _req, res) => {
4043
+ if (!isPinCheckError(err)) return;
4044
+ console.error("[Control Plane] /api proxy: TLS pin mismatch \u2014 gateway LOCKED:", err.message);
4045
+ lockAsTlsMismatch();
4046
+ if (res && "writeHead" in res && !res.headersSent) {
4047
+ res.writeHead(502, { "Content-Type": "application/json" });
4048
+ res.end(JSON.stringify({ error: "as_tls_mismatch", message: err.message }));
4049
+ }
4050
+ },
3577
4051
  proxyRes: (proxyRes, req) => {
3578
4052
  const status = proxyRes.statusCode ?? 0;
3579
4053
  if (status === 401 && vault.isUnlocked()) {
@@ -3627,7 +4101,7 @@ function detectRunningVersion() {
3627
4101
  const bundlePkg = join11(dir, "..", "..", "package.json");
3628
4102
  if (existsSync9(bundlePkg)) {
3629
4103
  try {
3630
- const pkg = JSON.parse(readFileSync10(bundlePkg, "utf8"));
4104
+ const pkg = JSON.parse(readFileSync11(bundlePkg, "utf8"));
3631
4105
  if (pkg.name === "@suveren/gateway" && typeof pkg.version === "string") {
3632
4106
  return pkg.version;
3633
4107
  }