@suveren/gateway 0.9.0 → 0.10.1

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 (238) hide show
  1. package/README.md +33 -1
  2. package/bin/suveren-gateway.js +203 -5
  3. package/content/integrations/crm.json +47 -3
  4. package/content/integrations/erp.json +231 -0
  5. package/content/integrations/index.json +3 -1
  6. package/content/integrations/mail.json +147 -0
  7. package/dist/control-plane/index.mjs +688 -179
  8. package/dist/mcp-server/http.mjs +483 -259
  9. package/dist/ui/assets/index-CV9Mbbkd.css +1 -0
  10. package/dist/ui/assets/{index-CIiOCgjJ.js → index-sq564t5H.js} +23 -23
  11. package/dist/ui/index.html +2 -2
  12. package/lib/as-pairing.mjs +77 -0
  13. package/lib/config.mjs +48 -0
  14. package/node_modules/@types/node/README.md +1 -1
  15. package/node_modules/@types/node/package.json +2 -2
  16. package/node_modules/@types/node/zlib.d.ts +6 -0
  17. package/node_modules/follow-redirects/index.js +10 -1
  18. package/node_modules/follow-redirects/package.json +1 -1
  19. package/node_modules/ip-address/README.md +34 -34
  20. package/node_modules/ip-address/dist/ipv4.js +7 -0
  21. package/node_modules/ip-address/dist/ipv4.js.map +1 -1
  22. package/node_modules/ip-address/package.json +1 -1
  23. package/node_modules/undici/LICENSE +21 -0
  24. package/node_modules/undici/README.md +741 -0
  25. package/node_modules/undici/docs/docs/api/Agent.md +84 -0
  26. package/node_modules/undici/docs/docs/api/BalancedPool.md +99 -0
  27. package/node_modules/undici/docs/docs/api/CacheStorage.md +30 -0
  28. package/node_modules/undici/docs/docs/api/CacheStore.md +164 -0
  29. package/node_modules/undici/docs/docs/api/Client.md +288 -0
  30. package/node_modules/undici/docs/docs/api/ClientStats.md +27 -0
  31. package/node_modules/undici/docs/docs/api/Connector.md +115 -0
  32. package/node_modules/undici/docs/docs/api/ContentType.md +57 -0
  33. package/node_modules/undici/docs/docs/api/Cookies.md +128 -0
  34. package/node_modules/undici/docs/docs/api/Debug.md +62 -0
  35. package/node_modules/undici/docs/docs/api/DiagnosticsChannel.md +317 -0
  36. package/node_modules/undici/docs/docs/api/Dispatcher.md +1393 -0
  37. package/node_modules/undici/docs/docs/api/EnvHttpProxyAgent.md +159 -0
  38. package/node_modules/undici/docs/docs/api/Errors.md +49 -0
  39. package/node_modules/undici/docs/docs/api/EventSource.md +45 -0
  40. package/node_modules/undici/docs/docs/api/Fetch.md +60 -0
  41. package/node_modules/undici/docs/docs/api/GlobalInstallation.md +139 -0
  42. package/node_modules/undici/docs/docs/api/H2CClient.md +263 -0
  43. package/node_modules/undici/docs/docs/api/MockAgent.md +603 -0
  44. package/node_modules/undici/docs/docs/api/MockCallHistory.md +197 -0
  45. package/node_modules/undici/docs/docs/api/MockCallHistoryLog.md +43 -0
  46. package/node_modules/undici/docs/docs/api/MockClient.md +81 -0
  47. package/node_modules/undici/docs/docs/api/MockErrors.md +12 -0
  48. package/node_modules/undici/docs/docs/api/MockPool.md +555 -0
  49. package/node_modules/undici/docs/docs/api/Pool.md +84 -0
  50. package/node_modules/undici/docs/docs/api/PoolStats.md +35 -0
  51. package/node_modules/undici/docs/docs/api/ProxyAgent.md +229 -0
  52. package/node_modules/undici/docs/docs/api/RedirectHandler.md +93 -0
  53. package/node_modules/undici/docs/docs/api/RetryAgent.md +50 -0
  54. package/node_modules/undici/docs/docs/api/RetryHandler.md +118 -0
  55. package/node_modules/undici/docs/docs/api/RoundRobinPool.md +145 -0
  56. package/node_modules/undici/docs/docs/api/SnapshotAgent.md +616 -0
  57. package/node_modules/undici/docs/docs/api/Socks5ProxyAgent.md +275 -0
  58. package/node_modules/undici/docs/docs/api/Util.md +25 -0
  59. package/node_modules/undici/docs/docs/api/WebSocket.md +141 -0
  60. package/node_modules/undici/docs/docs/api/api-lifecycle.md +91 -0
  61. package/node_modules/undici/docs/docs/best-practices/client-certificate.md +64 -0
  62. package/node_modules/undici/docs/docs/best-practices/crawling.md +58 -0
  63. package/node_modules/undici/docs/docs/best-practices/mocking-request.md +190 -0
  64. package/node_modules/undici/docs/docs/best-practices/proxy.md +127 -0
  65. package/node_modules/undici/docs/docs/best-practices/undici-vs-builtin-fetch.md +224 -0
  66. package/node_modules/undici/docs/docs/best-practices/writing-tests.md +63 -0
  67. package/node_modules/undici/index-fetch.js +65 -0
  68. package/node_modules/undici/index.d.ts +3 -0
  69. package/node_modules/undici/index.js +234 -0
  70. package/node_modules/undici/lib/api/abort-signal.js +59 -0
  71. package/node_modules/undici/lib/api/api-connect.js +110 -0
  72. package/node_modules/undici/lib/api/api-pipeline.js +252 -0
  73. package/node_modules/undici/lib/api/api-request.js +214 -0
  74. package/node_modules/undici/lib/api/api-stream.js +209 -0
  75. package/node_modules/undici/lib/api/api-upgrade.js +111 -0
  76. package/node_modules/undici/lib/api/index.js +7 -0
  77. package/node_modules/undici/lib/api/readable.js +580 -0
  78. package/node_modules/undici/lib/cache/memory-cache-store.js +279 -0
  79. package/node_modules/undici/lib/cache/sqlite-cache-store.js +467 -0
  80. package/node_modules/undici/lib/core/connect.js +153 -0
  81. package/node_modules/undici/lib/core/constants.js +143 -0
  82. package/node_modules/undici/lib/core/diagnostics.js +227 -0
  83. package/node_modules/undici/lib/core/errors.js +477 -0
  84. package/node_modules/undici/lib/core/request.js +485 -0
  85. package/node_modules/undici/lib/core/socks5-client.js +422 -0
  86. package/node_modules/undici/lib/core/socks5-utils.js +212 -0
  87. package/node_modules/undici/lib/core/symbols.js +75 -0
  88. package/node_modules/undici/lib/core/tree.js +160 -0
  89. package/node_modules/undici/lib/core/util.js +992 -0
  90. package/node_modules/undici/lib/dispatcher/agent.js +158 -0
  91. package/node_modules/undici/lib/dispatcher/balanced-pool.js +221 -0
  92. package/node_modules/undici/lib/dispatcher/client-h1.js +1746 -0
  93. package/node_modules/undici/lib/dispatcher/client-h2.js +1060 -0
  94. package/node_modules/undici/lib/dispatcher/client.js +668 -0
  95. package/node_modules/undici/lib/dispatcher/dispatcher-base.js +184 -0
  96. package/node_modules/undici/lib/dispatcher/dispatcher.js +48 -0
  97. package/node_modules/undici/lib/dispatcher/env-http-proxy-agent.js +146 -0
  98. package/node_modules/undici/lib/dispatcher/fixed-queue.js +135 -0
  99. package/node_modules/undici/lib/dispatcher/h2c-client.js +51 -0
  100. package/node_modules/undici/lib/dispatcher/pool-base.js +214 -0
  101. package/node_modules/undici/lib/dispatcher/pool.js +118 -0
  102. package/node_modules/undici/lib/dispatcher/proxy-agent.js +319 -0
  103. package/node_modules/undici/lib/dispatcher/retry-agent.js +35 -0
  104. package/node_modules/undici/lib/dispatcher/round-robin-pool.js +137 -0
  105. package/node_modules/undici/lib/dispatcher/socks5-proxy-agent.js +260 -0
  106. package/node_modules/undici/lib/encoding/index.js +33 -0
  107. package/node_modules/undici/lib/global.js +59 -0
  108. package/node_modules/undici/lib/handler/cache-handler.js +776 -0
  109. package/node_modules/undici/lib/handler/cache-revalidation-handler.js +124 -0
  110. package/node_modules/undici/lib/handler/decorator-handler.js +67 -0
  111. package/node_modules/undici/lib/handler/deduplication-handler.js +460 -0
  112. package/node_modules/undici/lib/handler/redirect-handler.js +238 -0
  113. package/node_modules/undici/lib/handler/retry-handler.js +504 -0
  114. package/node_modules/undici/lib/handler/unwrap-handler.js +106 -0
  115. package/node_modules/undici/lib/handler/wrap-handler.js +105 -0
  116. package/node_modules/undici/lib/interceptor/cache.js +621 -0
  117. package/node_modules/undici/lib/interceptor/decompress.js +551 -0
  118. package/node_modules/undici/lib/interceptor/deduplicate.js +117 -0
  119. package/node_modules/undici/lib/interceptor/dns.js +571 -0
  120. package/node_modules/undici/lib/interceptor/dump.js +99 -0
  121. package/node_modules/undici/lib/interceptor/redirect.js +21 -0
  122. package/node_modules/undici/lib/interceptor/response-error.js +95 -0
  123. package/node_modules/undici/lib/interceptor/retry.js +19 -0
  124. package/node_modules/undici/lib/llhttp/.gitkeep +0 -0
  125. package/node_modules/undici/lib/llhttp/constants.d.ts +195 -0
  126. package/node_modules/undici/lib/llhttp/constants.js +531 -0
  127. package/node_modules/undici/lib/llhttp/llhttp-wasm.js +15 -0
  128. package/node_modules/undici/lib/llhttp/llhttp_simd-wasm.js +15 -0
  129. package/node_modules/undici/lib/llhttp/utils.d.ts +2 -0
  130. package/node_modules/undici/lib/llhttp/utils.js +12 -0
  131. package/node_modules/undici/lib/mock/mock-agent.js +232 -0
  132. package/node_modules/undici/lib/mock/mock-call-history.js +248 -0
  133. package/node_modules/undici/lib/mock/mock-client.js +68 -0
  134. package/node_modules/undici/lib/mock/mock-errors.js +29 -0
  135. package/node_modules/undici/lib/mock/mock-interceptor.js +209 -0
  136. package/node_modules/undici/lib/mock/mock-pool.js +68 -0
  137. package/node_modules/undici/lib/mock/mock-symbols.js +32 -0
  138. package/node_modules/undici/lib/mock/mock-utils.js +486 -0
  139. package/node_modules/undici/lib/mock/pending-interceptors-formatter.js +43 -0
  140. package/node_modules/undici/lib/mock/snapshot-agent.js +353 -0
  141. package/node_modules/undici/lib/mock/snapshot-recorder.js +588 -0
  142. package/node_modules/undici/lib/mock/snapshot-utils.js +158 -0
  143. package/node_modules/undici/lib/util/cache.js +676 -0
  144. package/node_modules/undici/lib/util/date.js +670 -0
  145. package/node_modules/undici/lib/util/promise.js +28 -0
  146. package/node_modules/undici/lib/util/runtime-features.js +124 -0
  147. package/node_modules/undici/lib/util/stats.js +32 -0
  148. package/node_modules/undici/lib/util/timers.js +425 -0
  149. package/node_modules/undici/lib/web/cache/cache.js +864 -0
  150. package/node_modules/undici/lib/web/cache/cachestorage.js +152 -0
  151. package/node_modules/undici/lib/web/cache/util.js +45 -0
  152. package/node_modules/undici/lib/web/cookies/constants.js +12 -0
  153. package/node_modules/undici/lib/web/cookies/index.js +199 -0
  154. package/node_modules/undici/lib/web/cookies/parse.js +314 -0
  155. package/node_modules/undici/lib/web/cookies/util.js +352 -0
  156. package/node_modules/undici/lib/web/eventsource/eventsource-stream.js +494 -0
  157. package/node_modules/undici/lib/web/eventsource/eventsource.js +501 -0
  158. package/node_modules/undici/lib/web/eventsource/util.js +29 -0
  159. package/node_modules/undici/lib/web/fetch/LICENSE +21 -0
  160. package/node_modules/undici/lib/web/fetch/body.js +509 -0
  161. package/node_modules/undici/lib/web/fetch/constants.js +131 -0
  162. package/node_modules/undici/lib/web/fetch/data-url.js +596 -0
  163. package/node_modules/undici/lib/web/fetch/formdata-parser.js +586 -0
  164. package/node_modules/undici/lib/web/fetch/formdata.js +259 -0
  165. package/node_modules/undici/lib/web/fetch/global.js +40 -0
  166. package/node_modules/undici/lib/web/fetch/headers.js +719 -0
  167. package/node_modules/undici/lib/web/fetch/index.js +2413 -0
  168. package/node_modules/undici/lib/web/fetch/request.js +1115 -0
  169. package/node_modules/undici/lib/web/fetch/response.js +641 -0
  170. package/node_modules/undici/lib/web/fetch/util.js +1522 -0
  171. package/node_modules/undici/lib/web/infra/index.js +229 -0
  172. package/node_modules/undici/lib/web/subresource-integrity/Readme.md +9 -0
  173. package/node_modules/undici/lib/web/subresource-integrity/subresource-integrity.js +307 -0
  174. package/node_modules/undici/lib/web/webidl/index.js +1006 -0
  175. package/node_modules/undici/lib/web/websocket/connection.js +339 -0
  176. package/node_modules/undici/lib/web/websocket/constants.js +126 -0
  177. package/node_modules/undici/lib/web/websocket/events.js +331 -0
  178. package/node_modules/undici/lib/web/websocket/frame.js +133 -0
  179. package/node_modules/undici/lib/web/websocket/permessage-deflate.js +105 -0
  180. package/node_modules/undici/lib/web/websocket/receiver.js +507 -0
  181. package/node_modules/undici/lib/web/websocket/sender.js +109 -0
  182. package/node_modules/undici/lib/web/websocket/stream/websocketerror.js +104 -0
  183. package/node_modules/undici/lib/web/websocket/stream/websocketstream.js +496 -0
  184. package/node_modules/undici/lib/web/websocket/util.js +347 -0
  185. package/node_modules/undici/lib/web/websocket/websocket.js +758 -0
  186. package/node_modules/undici/package.json +152 -0
  187. package/node_modules/undici/scripts/strip-comments.js +10 -0
  188. package/node_modules/undici/types/README.md +6 -0
  189. package/node_modules/undici/types/agent.d.ts +32 -0
  190. package/node_modules/undici/types/api.d.ts +43 -0
  191. package/node_modules/undici/types/balanced-pool.d.ts +30 -0
  192. package/node_modules/undici/types/cache-interceptor.d.ts +179 -0
  193. package/node_modules/undici/types/cache.d.ts +36 -0
  194. package/node_modules/undici/types/client-stats.d.ts +15 -0
  195. package/node_modules/undici/types/client.d.ts +139 -0
  196. package/node_modules/undici/types/connector.d.ts +36 -0
  197. package/node_modules/undici/types/content-type.d.ts +21 -0
  198. package/node_modules/undici/types/cookies.d.ts +30 -0
  199. package/node_modules/undici/types/diagnostics-channel.d.ts +74 -0
  200. package/node_modules/undici/types/dispatcher.d.ts +275 -0
  201. package/node_modules/undici/types/env-http-proxy-agent.d.ts +22 -0
  202. package/node_modules/undici/types/errors.d.ts +177 -0
  203. package/node_modules/undici/types/eventsource.d.ts +66 -0
  204. package/node_modules/undici/types/fetch.d.ts +231 -0
  205. package/node_modules/undici/types/formdata.d.ts +114 -0
  206. package/node_modules/undici/types/global-dispatcher.d.ts +9 -0
  207. package/node_modules/undici/types/global-origin.d.ts +7 -0
  208. package/node_modules/undici/types/h2c-client.d.ts +73 -0
  209. package/node_modules/undici/types/handlers.d.ts +14 -0
  210. package/node_modules/undici/types/header.d.ts +160 -0
  211. package/node_modules/undici/types/index.d.ts +91 -0
  212. package/node_modules/undici/types/interceptors.d.ts +82 -0
  213. package/node_modules/undici/types/mock-agent.d.ts +68 -0
  214. package/node_modules/undici/types/mock-call-history.d.ts +111 -0
  215. package/node_modules/undici/types/mock-client.d.ts +27 -0
  216. package/node_modules/undici/types/mock-errors.d.ts +12 -0
  217. package/node_modules/undici/types/mock-interceptor.d.ts +94 -0
  218. package/node_modules/undici/types/mock-pool.d.ts +27 -0
  219. package/node_modules/undici/types/patch.d.ts +29 -0
  220. package/node_modules/undici/types/pool-stats.d.ts +19 -0
  221. package/node_modules/undici/types/pool.d.ts +41 -0
  222. package/node_modules/undici/types/proxy-agent.d.ts +29 -0
  223. package/node_modules/undici/types/readable.d.ts +68 -0
  224. package/node_modules/undici/types/retry-agent.d.ts +8 -0
  225. package/node_modules/undici/types/retry-handler.d.ts +125 -0
  226. package/node_modules/undici/types/round-robin-pool.d.ts +41 -0
  227. package/node_modules/undici/types/snapshot-agent.d.ts +109 -0
  228. package/node_modules/undici/types/socks5-proxy-agent.d.ts +25 -0
  229. package/node_modules/undici/types/util.d.ts +18 -0
  230. package/node_modules/undici/types/utility.d.ts +7 -0
  231. package/node_modules/undici/types/webidl.d.ts +347 -0
  232. package/node_modules/undici/types/websocket.d.ts +188 -0
  233. package/package.json +4 -2
  234. package/profiles/customers/0.8.profile.json +187 -0
  235. package/profiles/email/0.7.profile.json +254 -0
  236. package/profiles/index.json +4 -1
  237. package/profiles/sales/0.2.profile.json +282 -0
  238. package/dist/ui/assets/index-BJqF5rmx.css +0 -1
@@ -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,467 @@ 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
+ var DEFAULT_AS_FETCH_TIMEOUT_MS = 15e3;
835
+ function withBoundedSignal(init) {
836
+ const timeout = AbortSignal.timeout(DEFAULT_AS_FETCH_TIMEOUT_MS);
837
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
838
+ return { ...init, signal };
839
+ }
840
+ function closeAfter(dispatcher, pending) {
841
+ pending.catch(() => {
842
+ }).finally(() => {
843
+ void dispatcher.close().catch(() => {
844
+ });
845
+ });
846
+ }
847
+ async function fetchAs(url, init, pinning) {
848
+ if (!pinning.enforce && !pinning.capture) {
849
+ const res2 = await fetch(url, withBoundedSignal(init));
850
+ return { res: res2 };
851
+ }
852
+ if (pinning.enforce && !pinning.capture && !pinning.pinnedSpkiHex) {
853
+ throw new AsTlsMismatchError(
854
+ `pin-tls is on but no certificate is pinned yet for ${url} \u2014 sign in again to establish it (re-pairing required).`
855
+ );
856
+ }
857
+ if (pinning.pinnedSpkiHex && (pinning.enforce || !pinning.capture)) {
858
+ const dispatcher2 = buildPinnedDispatcher(pinning.pinnedSpkiHex);
859
+ const pending2 = undiciFetch(url, { ...withBoundedSignal(init), dispatcher: dispatcher2 });
860
+ closeAfter(dispatcher2, pending2);
861
+ try {
862
+ const res2 = await pending2;
863
+ return { res: res2 };
864
+ } catch (err) {
865
+ if (!isPinCheckError(err)) throw err;
866
+ throw new AsTlsMismatchError(
867
+ `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.`
868
+ );
869
+ }
870
+ }
871
+ const { dispatcher, getCapturedCert } = buildCapturingDispatcher();
872
+ const pending = undiciFetch(url, { ...withBoundedSignal(init), dispatcher });
873
+ closeAfter(dispatcher, pending);
874
+ let res;
875
+ try {
876
+ res = await pending;
877
+ } catch (err) {
878
+ if (!isPinCheckError(err)) throw err;
879
+ throw new AsTlsMismatchError(
880
+ `could not verify the Authority Server at ${url} while establishing its TLS pin \u2014 ${describeFetchError(err)}.`
881
+ );
882
+ }
883
+ const cert = getCapturedCert();
884
+ return { res, capturedSpkiHex: cert ? spkiSha256Hex(cert) : void 0 };
885
+ }
886
+ function describeFetchError(err) {
887
+ if (err instanceof Error) {
888
+ const cause = err.cause;
889
+ if (cause instanceof Error) return cause.message;
890
+ return err.message;
891
+ }
892
+ return String(err);
893
+ }
894
+
895
+ // src/lib/as-challenge.ts
896
+ var AsChallengeUnreachableError = class extends Error {
897
+ constructor(message) {
898
+ super(message);
899
+ this.name = "AsChallengeUnreachableError";
900
+ }
901
+ };
902
+ var AsChallengeInvalidError = class extends Error {
903
+ constructor(message) {
904
+ super(message);
905
+ this.name = "AsChallengeInvalidError";
906
+ }
907
+ };
908
+ var CHALLENGE_MAX_SKEW_SECONDS = 300;
909
+ var CHALLENGE_TYPE = "hap-as-challenge";
910
+ var CHALLENGE_DOMAIN_PREFIX = "hap-as-challenge\0";
911
+ function challengeSigningBytes(unsigned) {
912
+ return Buffer.concat([
913
+ Buffer.from(CHALLENGE_DOMAIN_PREFIX, "utf-8"),
914
+ Buffer.from(canonicalize({ typ: unsigned.typ, nonce: unsigned.nonce, issuedAt: unsigned.issuedAt }), "utf-8")
915
+ ]);
916
+ }
917
+ function publicKeyFromHex(publicKeyHex) {
918
+ const raw = Buffer.from(publicKeyHex, "hex");
919
+ return createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") }, format: "jwk" });
920
+ }
921
+ function verifyChallengeSignature(publicKeyHex, unsigned, signature) {
922
+ try {
923
+ const base64 = signature.replace(/-/g, "+").replace(/_/g, "/");
924
+ const padding = base64.length % 4 === 0 ? "" : "=".repeat(4 - base64.length % 4);
925
+ const sigBytes = Buffer.from(base64 + padding, "base64");
926
+ const key = publicKeyFromHex(publicKeyHex);
927
+ return cryptoVerify(null, challengeSigningBytes(unsigned), key, sigBytes);
928
+ } catch {
929
+ return false;
930
+ }
931
+ }
932
+ async function verifyAsHoldsKey(asUrl, publicKeyHex, pinning = { enforce: false, capture: true }) {
933
+ const nonce = randomBytes2(32).toString("base64url");
934
+ let res;
935
+ let capturedSpkiHex;
936
+ try {
937
+ const result = await fetchAs(
938
+ `${asUrl}/api/as/challenge`,
939
+ {
940
+ method: "POST",
941
+ headers: { "Content-Type": "application/json" },
942
+ body: JSON.stringify({ nonce }),
943
+ signal: AbortSignal.timeout(5e3)
944
+ },
945
+ pinning
946
+ );
947
+ res = result.res;
948
+ capturedSpkiHex = result.capturedSpkiHex;
949
+ } catch (err) {
950
+ if (err instanceof AsTlsMismatchError) throw err;
951
+ throw new AsChallengeUnreachableError(
952
+ `could not reach the Authority Server at ${asUrl} to verify it holds its signing key \u2014 ${err instanceof Error ? err.message : String(err)}`
953
+ );
954
+ }
955
+ if (!res.ok) {
956
+ throw new AsChallengeUnreachableError(
957
+ `the Authority Server at ${asUrl} answered its signing-key challenge with HTTP ${res.status}`
958
+ );
959
+ }
960
+ const body = await res.json().catch(() => null);
961
+ if (!body) {
962
+ throw new AsChallengeUnreachableError(
963
+ `the Authority Server at ${asUrl} returned a malformed response to its signing-key challenge`
964
+ );
965
+ }
966
+ if (body.typ !== CHALLENGE_TYPE) {
967
+ throw new AsChallengeInvalidError(
968
+ `the challenge response carries the wrong type ("${String(body.typ)}", expected "${CHALLENGE_TYPE}")`
969
+ );
970
+ }
971
+ if (body.nonce !== nonce) {
972
+ throw new AsChallengeInvalidError(
973
+ "the challenge response echoes a different nonce than the one sent"
974
+ );
975
+ }
976
+ if (typeof body.issuedAt !== "number" || !Number.isFinite(body.issuedAt)) {
977
+ throw new AsChallengeInvalidError("the challenge response carries no valid issuedAt");
978
+ }
979
+ const skew = Math.abs(Math.floor(Date.now() / 1e3) - body.issuedAt);
980
+ if (skew > CHALLENGE_MAX_SKEW_SECONDS) {
981
+ throw new AsChallengeInvalidError(
982
+ `the challenge response is timestamped ${skew}s away from now, beyond the ${CHALLENGE_MAX_SKEW_SECONDS}s tolerance`
983
+ );
984
+ }
985
+ if (typeof body.signature !== "string" || !body.signature) {
986
+ throw new AsChallengeInvalidError("the challenge response carries no signature");
987
+ }
988
+ if (!verifyChallengeSignature(publicKeyHex, { typ: body.typ, nonce: body.nonce, issuedAt: body.issuedAt }, body.signature)) {
989
+ throw new AsChallengeInvalidError(
990
+ "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."
991
+ );
992
+ }
993
+ return { capturedTlsSpkiHex: capturedSpkiHex };
994
+ }
995
+
996
+ // src/lib/as-config.ts
997
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
998
+ import { dirname as dirname3, join as join4 } from "path";
999
+ var DEFAULT_AS_URL = "https://www.suveren.ai";
1000
+ function configPath(dataDir) {
1001
+ return join4(dataDir, "config.json");
1002
+ }
1003
+ function readAsConfigStrict(dataDir) {
1004
+ const path = configPath(dataDir);
1005
+ if (!existsSync3(path)) return {};
1006
+ let data;
1007
+ try {
1008
+ data = JSON.parse(readFileSync5(path, "utf-8"));
1009
+ } catch (err) {
1010
+ throw new Error(
1011
+ `Could not parse ${path}: ${err instanceof Error ? err.message : String(err)}`
1012
+ );
1013
+ }
1014
+ const out = {};
1015
+ if (typeof data.asUrl === "string") out.asUrl = data.asUrl;
1016
+ if (typeof data.caFile === "string") out.caFile = data.caFile;
1017
+ if (typeof data.pinTls === "boolean") out.pinTls = data.pinTls;
1018
+ if (typeof data.pinTlsExpectedFingerprint === "string") out.pinTlsExpectedFingerprint = data.pinTlsExpectedFingerprint;
1019
+ return out;
1020
+ }
1021
+ function readAsConfig(dataDir) {
1022
+ const path = configPath(dataDir);
1023
+ if (!existsSync3(path)) return {};
1024
+ try {
1025
+ const data = JSON.parse(readFileSync5(path, "utf-8"));
1026
+ const out = {};
1027
+ if (typeof data.asUrl === "string") out.asUrl = data.asUrl;
1028
+ if (typeof data.caFile === "string") out.caFile = data.caFile;
1029
+ if (typeof data.pinTls === "boolean") out.pinTls = data.pinTls;
1030
+ if (typeof data.pinTlsExpectedFingerprint === "string") out.pinTlsExpectedFingerprint = data.pinTlsExpectedFingerprint;
1031
+ return out;
1032
+ } catch {
1033
+ return {};
1034
+ }
1035
+ }
1036
+ function writeAsConfig(dataDir, patch) {
1037
+ const merged = { ...readAsConfig(dataDir), ...patch };
1038
+ const path = configPath(dataDir);
1039
+ mkdirSync3(dirname3(path), { recursive: true, mode: 448 });
1040
+ writeFileSync3(path, JSON.stringify(merged, null, 2), { encoding: "utf-8", mode: 384 });
1041
+ return merged;
1042
+ }
1043
+ function validateAsUrl(candidate) {
1044
+ const trimmed = candidate.trim();
1045
+ if (!trimmed) return { ok: false, error: "The Authority Server URL is empty." };
1046
+ let parsed;
1047
+ try {
1048
+ parsed = new URL(trimmed);
1049
+ } catch {
1050
+ return { ok: false, error: `"${trimmed}" is not a valid URL.` };
1051
+ }
1052
+ const isLocalHttp = parsed.protocol === "http:" && (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1");
1053
+ if (parsed.protocol !== "https:" && !isLocalHttp) {
1054
+ return {
1055
+ ok: false,
1056
+ error: `"${trimmed}" must use https:// (http:// is only allowed for http://localhost or http://127.0.0.1).`
1057
+ };
1058
+ }
1059
+ let normalized = `${parsed.origin}${parsed.pathname}${parsed.search}${parsed.hash}`;
1060
+ if (normalized.endsWith("/") && normalized !== `${parsed.origin}/`) {
1061
+ normalized = normalized.slice(0, -1);
1062
+ } else if (normalized === `${parsed.origin}/`) {
1063
+ normalized = parsed.origin;
1064
+ }
1065
+ return { ok: true, url: normalized };
1066
+ }
1067
+ function resolveAsUrl(dataDir, flag) {
1068
+ if (flag) {
1069
+ const v = validateAsUrl(flag);
1070
+ if (!v.ok) throw new Error(`Invalid --as-url: ${v.error}`);
1071
+ return v.url;
1072
+ }
1073
+ const envUrl = process.env.SUVEREN_AS_URL;
1074
+ if (envUrl) {
1075
+ const v = validateAsUrl(envUrl);
1076
+ if (!v.ok) throw new Error(`Invalid SUVEREN_AS_URL: ${v.error}`);
1077
+ return v.url;
1078
+ }
1079
+ const saved = readAsConfigStrict(dataDir).asUrl;
1080
+ if (saved) {
1081
+ const v = validateAsUrl(saved);
1082
+ if (!v.ok) throw new Error(`Invalid saved as-url "${saved}" in ${dataDir}/config.json: ${v.error}`);
1083
+ return v.url;
1084
+ }
1085
+ return DEFAULT_AS_URL;
1086
+ }
1087
+ function resolvePinTls(dataDir) {
1088
+ return readAsConfig(dataDir).pinTls === true;
1089
+ }
1090
+ function validatePinTlsForUrl(pinTls, asUrl) {
1091
+ if (!pinTls) return;
1092
+ if (!asUrl.startsWith("https:")) {
1093
+ throw new Error(
1094
+ `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\`.`
1095
+ );
1096
+ }
1097
+ }
1098
+ function resolvePinTlsExpectedFingerprint(dataDir) {
1099
+ return readAsConfig(dataDir).pinTlsExpectedFingerprint;
1100
+ }
1101
+ function clearPinTlsExpectedFingerprint(dataDir) {
1102
+ if (readAsConfig(dataDir).pinTlsExpectedFingerprint === void 0) return;
1103
+ writeAsConfig(dataDir, { pinTlsExpectedFingerprint: void 0 });
1104
+ }
1105
+
701
1106
  // src/routes/auth.ts
702
1107
  var DEFAULT_SP_URL = process.env.SUVEREN_AS_URL ?? "https://www.suveren.ai";
703
- async function checkAsKeyBeforeLogin(asUrl, dataDir) {
704
- let publicKeyHex;
1108
+ async function fetchAsPublicKey(asUrl) {
705
1109
  try {
706
1110
  const res = await fetch(`${asUrl}/api/as/pubkey`, { signal: AbortSignal.timeout(5e3) });
707
1111
  if (!res.ok) {
708
1112
  return {
709
1113
  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.`
1114
+ message: `Could not reach the Authority Server at ${asUrl} to read its signing key (HTTP ${res.status}). Refusing to sign in.`
712
1115
  };
713
1116
  }
714
1117
  const body = await res.json().catch(() => null);
715
1118
  if (!body || typeof body.publicKey !== "string" || !body.publicKey) {
716
1119
  return {
717
1120
  ok: false,
718
- error: "as_unreachable",
719
1121
  message: `The Authority Server at ${asUrl} returned a malformed signing key. Refusing to sign in.`
720
1122
  };
721
1123
  }
722
- publicKeyHex = body.publicKey;
1124
+ return { ok: true, publicKeyHex: body.publicKey };
723
1125
  } catch (err) {
724
1126
  return {
725
1127
  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.`
1128
+ 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
1129
  };
729
1130
  }
1131
+ }
1132
+ async function checkAsKeyBeforeLogin(asUrl, dataDir) {
730
1133
  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
- };
1134
+ const pinnedKey = existing && existing.asUrl === asUrl ? existing.publicKeyHex : void 0;
1135
+ const existingTlsPin = (existing && existing.asUrl === asUrl ? existing.tlsSpkiPinHex : void 0) ?? resolvePinTlsExpectedFingerprint(dataDir);
1136
+ let candidateKey;
1137
+ if (pinnedKey) {
1138
+ candidateKey = pinnedKey;
1139
+ } else {
1140
+ const fetched = await fetchAsPublicKey(asUrl);
1141
+ if (!fetched.ok) {
1142
+ return { ok: false, error: "as_unreachable", message: fetched.message };
1143
+ }
1144
+ candidateKey = fetched.publicKeyHex;
1145
+ }
1146
+ const pinning = {
1147
+ enforce: resolvePinTls(dataDir),
1148
+ pinnedSpkiHex: existingTlsPin,
1149
+ capture: true
1150
+ };
1151
+ let capturedTlsSpkiHex;
1152
+ try {
1153
+ const result = await verifyAsHoldsKey(asUrl, candidateKey, pinning);
1154
+ capturedTlsSpkiHex = result.capturedTlsSpkiHex;
1155
+ } catch (err) {
1156
+ if (err instanceof AsTlsMismatchError) {
1157
+ return { ok: false, error: "as_tls_mismatch", message: err.message };
1158
+ }
1159
+ if (err instanceof AsChallengeUnreachableError) {
1160
+ return { ok: false, error: "as_unverified", message: `${err.message}. Refusing to sign in.` };
1161
+ }
1162
+ if (err instanceof AsChallengeInvalidError) {
1163
+ if (pinnedKey) {
1164
+ return {
1165
+ ok: false,
1166
+ error: "as_key_mismatch",
1167
+ 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.`
1168
+ };
1169
+ }
1170
+ return {
1171
+ ok: false,
1172
+ error: "as_unverified",
1173
+ message: `The Authority Server at ${asUrl} could not prove it holds the signing key it presented \u2014 ${err.message}. Refusing to sign in.`
1174
+ };
1175
+ }
1176
+ throw err;
737
1177
  }
738
- return { ok: true, publicKeyHex };
1178
+ return {
1179
+ ok: true,
1180
+ publicKeyHex: candidateKey,
1181
+ // A fresh capture (always present for https, unless pin-tls is on and
1182
+ // an existing pin was enforced instead — see fetchAs) wins; otherwise
1183
+ // whatever was already on file carries through unchanged.
1184
+ tlsSpkiPinHex: capturedTlsSpkiHex ?? existingTlsPin
1185
+ };
739
1186
  }
740
- function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablished, pairing) {
1187
+ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablished, pairing, lockAsTlsMismatch2 = () => {
1188
+ }) {
741
1189
  const router = Router();
742
1190
  const SP_URL2 = pairing?.asUrl ?? DEFAULT_SP_URL;
743
1191
  router.post("/login", loginRateLimit2, async (req, res) => {
@@ -752,24 +1200,52 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
752
1200
  if (pairing) {
753
1201
  keyCheck = await checkAsKeyBeforeLogin(pairing.asUrl, pairing.dataDir);
754
1202
  if (!keyCheck.ok) {
755
- res.status(keyCheck.error === "as_key_mismatch" ? 409 : 502).json({
1203
+ const status = keyCheck.error === "as_key_mismatch" || keyCheck.error === "as_tls_mismatch" ? 409 : 502;
1204
+ res.status(status).json({
756
1205
  error: keyCheck.error,
757
1206
  message: keyCheck.message
758
1207
  });
759
1208
  return;
760
1209
  }
761
1210
  }
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()
1211
+ const sessionPinning = {
1212
+ enforce: Boolean(pairing && resolvePinTls(pairing.dataDir)),
1213
+ pinnedSpkiHex: keyCheck?.tlsSpkiPinHex
1214
+ };
1215
+ let spRes;
1216
+ try {
1217
+ const result = await fetchAs(
1218
+ `${SP_URL2}/api/auth/session`,
1219
+ {
1220
+ method: "POST",
1221
+ headers: {
1222
+ "Content-Type": "application/json",
1223
+ "X-API-Key": apiKey,
1224
+ // The AS grants a 30-day gateway session ONLY to a login that
1225
+ // identifies itself this way; without it, a 24-hour browser
1226
+ // session (see doc at the top of this file).
1227
+ ...clientVersionHeaders()
1228
+ },
1229
+ // Belt-and-suspenders on top of fetchAs's own default bound
1230
+ // (as-tls-pin.ts): this is the exact call that hung in
1231
+ // production (2026-10-02) with NO timeout at all — a dead
1232
+ // keep-alive socket left the whole login request stuck forever.
1233
+ signal: AbortSignal.timeout(1e4)
1234
+ },
1235
+ sessionPinning
1236
+ );
1237
+ spRes = result.res;
1238
+ } catch (err) {
1239
+ if (err instanceof AsTlsMismatchError) {
1240
+ res.status(409).json({ error: "as_tls_mismatch", message: err.message });
1241
+ return;
771
1242
  }
772
- });
1243
+ res.status(502).json({
1244
+ error: "as_unreachable",
1245
+ message: `Could not reach the Authority Server at ${SP_URL2} to sign in \u2014 ${err instanceof Error ? err.message : String(err)}. Refusing to sign in.`
1246
+ });
1247
+ return;
1248
+ }
773
1249
  if (!spRes.ok) {
774
1250
  const err = await spRes.json().catch(() => ({ error: "Invalid API key" }));
775
1251
  res.status(spRes.status).json(err);
@@ -778,9 +1254,15 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
778
1254
  if (pairing && keyCheck?.publicKeyHex) {
779
1255
  const existing = readPairing(pairing.dataDir);
780
1256
  if (!existing || existing.asUrl !== pairing.asUrl) {
781
- writePairing(pairing.dataDir, pairing.asUrl, keyCheck.publicKeyHex);
1257
+ writePairing(pairing.dataDir, pairing.asUrl, keyCheck.publicKeyHex, {
1258
+ tlsSpkiPinHex: keyCheck.tlsSpkiPinHex
1259
+ });
782
1260
  console.error(`[Control Plane] Paired with Authority Server ${pairing.asUrl} (fingerprint ${fingerprintOf(keyCheck.publicKeyHex)})`);
1261
+ } else if (keyCheck.tlsSpkiPinHex && keyCheck.tlsSpkiPinHex !== existing.tlsSpkiPinHex) {
1262
+ recordTlsPin(pairing.dataDir, pairing.asUrl, keyCheck.tlsSpkiPinHex);
1263
+ console.error(`[Control Plane] TLS pin for Authority Server ${pairing.asUrl} captured/updated.`);
783
1264
  }
1265
+ clearPinTlsExpectedFingerprint(pairing.dataDir);
784
1266
  }
785
1267
  const data = await spRes.json();
786
1268
  const setCookieHeaders = spRes.headers.getSetCookie?.() ?? [];
@@ -828,10 +1310,11 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
828
1310
  const kp = await loadOrGenerateKeyPair(vault2);
829
1311
  const localPubkeyB64 = Buffer.from(kp.publicKey).toString("base64");
830
1312
  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
- });
1313
+ const { res: meKeyRes } = await fetchAs(
1314
+ `${SP_URL2}/api/users/me/pubkey`,
1315
+ { headers: spCookie ? { Cookie: spCookie } : {}, signal: AbortSignal.timeout(5e3) },
1316
+ sessionPinning
1317
+ );
835
1318
  let needsUpdate = false;
836
1319
  if (meKeyRes.status === 404) {
837
1320
  needsUpdate = true;
@@ -840,15 +1323,19 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
840
1323
  needsUpdate = meKeyData.pubkey !== localPubkeyB64;
841
1324
  }
842
1325
  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 } : {}
1326
+ const { res: putRes } = await fetchAs(
1327
+ `${SP_URL2}/api/users/me/pubkey`,
1328
+ {
1329
+ method: "PUT",
1330
+ headers: {
1331
+ "Content-Type": "application/json",
1332
+ ...spCookie ? { Cookie: spCookie } : {}
1333
+ },
1334
+ body: JSON.stringify({ pubkey: localPubkeyB64 }),
1335
+ signal: AbortSignal.timeout(5e3)
848
1336
  },
849
- body: JSON.stringify({ pubkey: localPubkeyB64 }),
850
- signal: AbortSignal.timeout(5e3)
851
- });
1337
+ sessionPinning
1338
+ );
852
1339
  if (!putRes.ok) {
853
1340
  console.error(`[Control Plane] E2EE pubkey registration failed: ${putRes.status}`);
854
1341
  } else {
@@ -858,7 +1345,12 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
858
1345
  console.error("[Control Plane] E2EE pubkey already up to date");
859
1346
  }
860
1347
  } catch (err) {
861
- console.error("[Control Plane] E2EE pubkey auto-register failed:", err);
1348
+ if (err instanceof AsTlsMismatchError) {
1349
+ console.error("[Control Plane] E2EE pubkey auto-register aborted \u2014 TLS pin mismatch:", err.message);
1350
+ lockAsTlsMismatch2();
1351
+ } else {
1352
+ console.error("[Control Plane] E2EE pubkey auto-register failed:", err);
1353
+ }
862
1354
  }
863
1355
  for (const credId of vault2.listCredentials()) {
864
1356
  try {
@@ -895,12 +1387,29 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
895
1387
  router.post("/logout", logoutAuth, async (_req, res) => {
896
1388
  const cookie = vault2.getSpCookie();
897
1389
  vault2.clearKey();
1390
+ const logoutPinning = pairing ? { enforce: resolvePinTls(pairing.dataDir), pinnedSpkiHex: readPairing(pairing.dataDir)?.tlsSpkiPinHex } : { enforce: false };
898
1391
  await Promise.allSettled([
899
1392
  unconfigureSession(),
900
- cookie ? fetch(`${SP_URL2}/api/auth/logout`, {
901
- method: "POST",
902
- headers: { cookie, ...clientVersionHeaders() },
903
- redirect: "manual"
1393
+ cookie ? fetchAs(
1394
+ `${SP_URL2}/api/auth/logout`,
1395
+ {
1396
+ method: "POST",
1397
+ headers: { cookie, ...clientVersionHeaders() },
1398
+ redirect: "manual",
1399
+ // Same reasoning as the session call above — this sat with no
1400
+ // timeout of its own before, which would have hung the
1401
+ // `Promise.allSettled` below (and this handler's response)
1402
+ // forever on a dead connection.
1403
+ signal: AbortSignal.timeout(1e4)
1404
+ },
1405
+ logoutPinning
1406
+ ).catch((err) => {
1407
+ if (err instanceof AsTlsMismatchError) {
1408
+ console.error("[Control Plane] Logout: TLS pin mismatch talking to the Authority Server \u2014 local sign-out still applied:", err.message);
1409
+ lockAsTlsMismatch2();
1410
+ } else {
1411
+ throw err;
1412
+ }
904
1413
  }) : Promise.resolve()
905
1414
  ]);
906
1415
  res.json({ ok: true });
@@ -1512,18 +2021,18 @@ async function testAIConnectivity(config) {
1512
2021
  }
1513
2022
 
1514
2023
  // src/lib/agent-brief-store.ts
1515
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
2024
+ import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
1516
2025
  import { homedir as homedir2 } from "os";
1517
- import { join as join4 } from "path";
2026
+ import { join as join5 } from "path";
1518
2027
  var AGENT_CONTEXT_MAX_BYTES = 16 * 1024;
1519
2028
  function agentBriefPath() {
1520
- const dataDir = process.env.SUVEREN_DATA_DIR ?? join4(homedir2(), ".suveren");
1521
- return join4(dataDir, "context.md");
2029
+ const dataDir = process.env.SUVEREN_DATA_DIR ?? join5(homedir2(), ".suveren");
2030
+ return join5(dataDir, "context.md");
1522
2031
  }
1523
2032
  function readAgentBrief() {
1524
2033
  const filePath = agentBriefPath();
1525
- if (!existsSync3(filePath)) return "";
1526
- return readFileSync4(filePath, "utf-8");
2034
+ if (!existsSync4(filePath)) return "";
2035
+ return readFileSync6(filePath, "utf-8");
1527
2036
  }
1528
2037
 
1529
2038
  // src/routes/ai.ts
@@ -1887,7 +2396,7 @@ function createMCPRouter() {
1887
2396
  // src/routes/autostart.ts
1888
2397
  import { Router as Router6 } from "express";
1889
2398
  import { execFile } from "child_process";
1890
- import { existsSync as existsSync4 } from "fs";
2399
+ import { existsSync as existsSync5 } from "fs";
1891
2400
  import { dirname as pathDirname, resolve } from "path";
1892
2401
  import { fileURLToPath } from "url";
1893
2402
  import { promisify } from "util";
@@ -1908,7 +2417,7 @@ function findCli(dirname9 = MODULE_DIR) {
1908
2417
  // resolves differently between a shell and a login service — and it would
1909
2418
  // mask a genuinely missing CLI whenever the cwd happened to be the repo.
1910
2419
  ];
1911
- return candidates.find(existsSync4) ?? null;
2420
+ return candidates.find(existsSync5) ?? null;
1912
2421
  }
1913
2422
  async function cli(args) {
1914
2423
  const bin = findCli();
@@ -1989,17 +2498,17 @@ function createAutostartRouter() {
1989
2498
  import { spawn, spawnSync } from "child_process";
1990
2499
 
1991
2500
  // src/lib/notifier-icon.ts
1992
- import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, existsSync as existsSync5 } from "fs";
2501
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync6 } from "fs";
1993
2502
  import { homedir as homedir3 } from "os";
1994
- import { join as join5, dirname as dirname3 } from "path";
2503
+ import { join as join6, dirname as dirname4 } from "path";
1995
2504
  var ICON_BASE64 = "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";
1996
2505
  function ensureNotifierIcon(dataDir) {
1997
2506
  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"));
2507
+ const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join6(homedir3(), ".suveren");
2508
+ const path = join6(dir, "notifier-icon.png");
2509
+ if (!existsSync6(path)) {
2510
+ mkdirSync4(dirname4(path), { recursive: true });
2511
+ writeFileSync4(path, Buffer.from(ICON_BASE64, "base64"));
2003
2512
  }
2004
2513
  return path;
2005
2514
  } catch {
@@ -2201,24 +2710,24 @@ import { Router as Router9 } from "express";
2201
2710
 
2202
2711
  // src/routes/approved-intents.ts
2203
2712
  import { Router as Router8 } from "express";
2204
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, existsSync as existsSync6, mkdirSync as mkdirSync4 } from "fs";
2713
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync5, existsSync as existsSync7, mkdirSync as mkdirSync5 } from "fs";
2205
2714
  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");
2715
+ import { join as join7 } from "path";
2716
+ var SUVEREN_DATA_DIR = process.env.SUVEREN_DATA_DIR ?? join7(homedir4(), ".suveren");
2717
+ var FILE_PATH = join7(SUVEREN_DATA_DIR, "approved-intents.enc.json");
2209
2718
  function readFile() {
2210
- if (!existsSync6(FILE_PATH)) {
2719
+ if (!existsSync7(FILE_PATH)) {
2211
2720
  return { version: 1, entries: {} };
2212
2721
  }
2213
2722
  try {
2214
- return JSON.parse(readFileSync5(FILE_PATH, "utf-8"));
2723
+ return JSON.parse(readFileSync7(FILE_PATH, "utf-8"));
2215
2724
  } catch {
2216
2725
  return { version: 1, entries: {} };
2217
2726
  }
2218
2727
  }
2219
2728
  function writeFile(data) {
2220
- mkdirSync4(SUVEREN_DATA_DIR, { recursive: true });
2221
- writeFileSync4(FILE_PATH, JSON.stringify(data, null, 2), "utf-8");
2729
+ mkdirSync5(SUVEREN_DATA_DIR, { recursive: true });
2730
+ writeFileSync5(FILE_PATH, JSON.stringify(data, null, 2), "utf-8");
2222
2731
  }
2223
2732
  function readApprovedIntents(vault2) {
2224
2733
  const file = readFile();
@@ -2267,7 +2776,7 @@ function createApprovedIntentsRouter(vault2) {
2267
2776
 
2268
2777
  // src/routes/evidence-export.ts
2269
2778
  var AS_FETCH_TIMEOUT_MS = 2e4;
2270
- function createEvidenceExportRouter(spUrl, vault2) {
2779
+ function createEvidenceExportRouter(spUrl, vault2, dataDir, lockAsTlsMismatch2) {
2271
2780
  const router = Router9();
2272
2781
  const getSpCookie = () => vault2.getSpCookie();
2273
2782
  router.get("/", async (_req, res) => {
@@ -2287,17 +2796,29 @@ function createEvidenceExportRouter(spUrl, vault2) {
2287
2796
  const controller = new AbortController();
2288
2797
  const timeout = setTimeout(() => controller.abort(), AS_FETCH_TIMEOUT_MS);
2289
2798
  try {
2290
- const asRes = await fetch(`${spUrl}/api/receipts/export`, {
2291
- headers: { Cookie: cookie },
2292
- signal: controller.signal
2293
- });
2799
+ const pin = readPairing(dataDir);
2800
+ const { res: asRes } = await fetchAs(
2801
+ `${spUrl}/api/receipts/export`,
2802
+ { headers: { Cookie: cookie }, signal: controller.signal },
2803
+ {
2804
+ enforce: resolvePinTls(dataDir),
2805
+ pinnedSpkiHex: pin && pin.asUrl === spUrl ? pin.tlsSpkiPinHex : void 0
2806
+ // Never capture here — only the sign-in challenge establishes or
2807
+ // refreshes a pin (as-challenge.ts).
2808
+ }
2809
+ );
2294
2810
  if (asRes.ok) {
2295
2811
  authorityServer = await asRes.json();
2296
2812
  } else {
2297
2813
  asError = `Authority Server export failed: ${asRes.status}`;
2298
2814
  }
2299
2815
  } catch (err) {
2300
- asError = `Authority Server unreachable: ${err instanceof Error ? err.message : String(err)}`;
2816
+ if (err instanceof AsTlsMismatchError) {
2817
+ lockAsTlsMismatch2();
2818
+ asError = err.message;
2819
+ } else {
2820
+ asError = `Authority Server unreachable: ${err instanceof Error ? err.message : String(err)}`;
2821
+ }
2301
2822
  } finally {
2302
2823
  clearTimeout(timeout);
2303
2824
  }
@@ -2483,7 +3004,7 @@ function createDecryptIntentRouter(vault2) {
2483
3004
 
2484
3005
  // src/lib/update-checker.ts
2485
3006
  import { execSync } from "child_process";
2486
- import { dirname as dirname4 } from "path";
3007
+ import { dirname as dirname5 } from "path";
2487
3008
  import { fileURLToPath as fileURLToPath2 } from "url";
2488
3009
  var GHCR_IMAGE = "suverenai/suveren-gateway";
2489
3010
  var NPM_PACKAGE = "@suveren/gateway";
@@ -2549,7 +3070,7 @@ function compareSemver(a, b) {
2549
3070
  return 0;
2550
3071
  }
2551
3072
  function checkDev() {
2552
- const cwd = dirname4(fileURLToPath2(import.meta.url));
3073
+ const cwd = dirname5(fileURLToPath2(import.meta.url));
2553
3074
  try {
2554
3075
  execSync("git fetch origin --quiet", { cwd, stdio: "ignore", timeout: 15e3 });
2555
3076
  const out = execSync("git rev-list HEAD..origin/main --count", {
@@ -2622,19 +3143,19 @@ data: ${data}
2622
3143
  import { Router as Router12 } from "express";
2623
3144
 
2624
3145
  // src/lib/gateway-settings.ts
2625
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, mkdirSync as mkdirSync5 } from "fs";
3146
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync6, mkdirSync as mkdirSync6 } from "fs";
2626
3147
  import { homedir as homedir5 } from "os";
2627
- import { join as join7, dirname as dirname5 } from "path";
3148
+ import { join as join8, dirname as dirname6 } from "path";
2628
3149
  var DEFAULT_SETTINGS = {
2629
3150
  desktopNotifications: true
2630
3151
  };
2631
3152
  function settingsPath(dataDir) {
2632
- const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join7(homedir5(), ".suveren");
2633
- return join7(dir, "settings.json");
3153
+ const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join8(homedir5(), ".suveren");
3154
+ return join8(dir, "settings.json");
2634
3155
  }
2635
3156
  function readSettings(dataDir) {
2636
3157
  try {
2637
- const raw = JSON.parse(readFileSync6(settingsPath(dataDir), "utf8"));
3158
+ const raw = JSON.parse(readFileSync8(settingsPath(dataDir), "utf8"));
2638
3159
  return {
2639
3160
  desktopNotifications: typeof raw.desktopNotifications === "boolean" ? raw.desktopNotifications : DEFAULT_SETTINGS.desktopNotifications
2640
3161
  };
@@ -2648,8 +3169,8 @@ function writeSettings(patch, dataDir) {
2648
3169
  ...typeof patch.desktopNotifications === "boolean" ? { desktopNotifications: patch.desktopNotifications } : {}
2649
3170
  };
2650
3171
  const path = settingsPath(dataDir);
2651
- mkdirSync5(dirname5(path), { recursive: true });
2652
- writeFileSync5(path, JSON.stringify(next, null, 2) + "\n", "utf8");
3172
+ mkdirSync6(dirname6(path), { recursive: true });
3173
+ writeFileSync6(path, JSON.stringify(next, null, 2) + "\n", "utf8");
2653
3174
  return next;
2654
3175
  }
2655
3176
 
@@ -2681,20 +3202,20 @@ function createGatewaySettingsRouter() {
2681
3202
  import { Router as Router13 } from "express";
2682
3203
 
2683
3204
  // src/lib/archived-mandates-store.ts
2684
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, mkdirSync as mkdirSync6, renameSync } from "fs";
3205
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync7, mkdirSync as mkdirSync7, renameSync } from "fs";
2685
3206
  import { homedir as homedir6 } from "os";
2686
- import { join as join8, dirname as dirname6 } from "path";
3207
+ import { join as join9, dirname as dirname7 } from "path";
2687
3208
  var ID_PATTERN = /^[A-Za-z0-9_.:-]{1,200}$/;
2688
3209
  function isValidMandateId(id) {
2689
3210
  return typeof id === "string" && ID_PATTERN.test(id);
2690
3211
  }
2691
3212
  function storePath(dataDir) {
2692
- const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join8(homedir6(), ".suveren");
2693
- return join8(dir, "archived-mandates.json");
3213
+ const dir = dataDir ?? process.env.SUVEREN_DATA_DIR ?? join9(homedir6(), ".suveren");
3214
+ return join9(dir, "archived-mandates.json");
2694
3215
  }
2695
3216
  function readArchived(dataDir) {
2696
3217
  try {
2697
- const raw = JSON.parse(readFileSync7(storePath(dataDir), "utf8"));
3218
+ const raw = JSON.parse(readFileSync9(storePath(dataDir), "utf8"));
2698
3219
  if (!Array.isArray(raw.archived)) return [];
2699
3220
  return [...new Set(raw.archived.filter(isValidMandateId))];
2700
3221
  } catch {
@@ -2704,9 +3225,9 @@ function readArchived(dataDir) {
2704
3225
  function writeArchived(ids, dataDir) {
2705
3226
  const next = [...new Set(ids.filter(isValidMandateId))].sort();
2706
3227
  const path = storePath(dataDir);
2707
- mkdirSync6(dirname6(path), { recursive: true });
3228
+ mkdirSync7(dirname7(path), { recursive: true });
2708
3229
  const tmp = `${path}.tmp`;
2709
- writeFileSync6(tmp, JSON.stringify({ archived: next }, null, 2) + "\n", "utf8");
3230
+ writeFileSync7(tmp, JSON.stringify({ archived: next }, null, 2) + "\n", "utf8");
2710
3231
  renameSync(tmp, path);
2711
3232
  return next;
2712
3233
  }
@@ -2748,6 +3269,7 @@ import { timingSafeEqual as timingSafeEqual2 } from "crypto";
2748
3269
  var ALLOWED = ["proposal-added", "action-approval-needed"];
2749
3270
  var SESSION_EXPIRED = "session-expired";
2750
3271
  var AS_KEY_MISMATCH = "as-key-mismatch";
3272
+ var AS_TLS_MISMATCH = "as-tls-mismatch";
2751
3273
  function secretMatches(provided, expected) {
2752
3274
  if (!expected) return false;
2753
3275
  if (!provided) return false;
@@ -2756,7 +3278,7 @@ function secretMatches(provided, expected) {
2756
3278
  if (a.length !== b.length) return false;
2757
3279
  return timingSafeEqual2(a, b);
2758
3280
  }
2759
- function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch) {
3281
+ function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch, onAsTlsMismatch) {
2760
3282
  const router = Router14();
2761
3283
  router.post("/event", (req, res) => {
2762
3284
  if (!secretMatches(req.headers["x-internal-secret"], getSecret())) {
@@ -2774,6 +3296,11 @@ function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch
2774
3296
  res.json({ ok: true });
2775
3297
  return;
2776
3298
  }
3299
+ if (type === AS_TLS_MISMATCH) {
3300
+ onAsTlsMismatch?.();
3301
+ res.json({ ok: true });
3302
+ return;
3303
+ }
2777
3304
  if (typeof type !== "string" || !ALLOWED.includes(type)) {
2778
3305
  res.status(400).json({ error: "Unsupported event type" });
2779
3306
  return;
@@ -2873,6 +3400,7 @@ function startNotificationDispatcher(opts = {}) {
2873
3400
  // src/lib/session-lock.ts
2874
3401
  var SESSION_LOCKED_MESSAGE = "Your sign-in ended after 30 days or was revoked. Sign in again.";
2875
3402
  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.";
3403
+ 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
3404
  function createSessionLock(deps) {
2877
3405
  const { vault: vault2, port: port2 } = deps;
2878
3406
  const notifyFn = deps.notifyFn ?? notify;
@@ -2907,6 +3435,23 @@ function createAsKeyMismatchLock(deps) {
2907
3435
  notifyFn(title, AS_KEY_MISMATCH_MESSAGE, process.platform, url);
2908
3436
  };
2909
3437
  }
3438
+ function createAsTlsMismatchLock(deps) {
3439
+ const { vault: vault2, port: port2 } = deps;
3440
+ const notifyFn = deps.notifyFn ?? notify;
3441
+ const unconfigureSessionFn = deps.unconfigureSessionFn ?? unconfigureSession;
3442
+ const emit = deps.emit ?? eventBus.emit.bind(eventBus);
3443
+ return function lockOnAsTlsMismatch() {
3444
+ if (!vault2.isUnlocked()) return;
3445
+ vault2.lockAsTlsMismatch();
3446
+ console.error("[Control Plane] Authority Server TLS pin mismatch \u2014 gateway LOCKED");
3447
+ void unconfigureSessionFn("as-tls-mismatch").catch((err) => {
3448
+ console.error("[Control Plane] Failed to push cleared session to MCP:", err);
3449
+ });
3450
+ emit("session-locked", { reason: "as-tls-mismatch", message: AS_TLS_MISMATCH_MESSAGE });
3451
+ const { title, url } = sessionExpiredNotification(port2);
3452
+ notifyFn(title, AS_TLS_MISMATCH_MESSAGE, process.platform, url);
3453
+ };
3454
+ }
2910
3455
 
2911
3456
  // src/lib/session-expiry-scheduler.ts
2912
3457
  var MAX_TIMEOUT_MS = 2147483647;
@@ -2976,17 +3521,17 @@ function buildSessionHealth(vault2) {
2976
3521
  }
2977
3522
 
2978
3523
  // src/lib/denials-reader.ts
2979
- import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
2980
- import { join as join9 } from "path";
3524
+ import { existsSync as existsSync8, readFileSync as readFileSync10 } from "fs";
3525
+ import { join as join10 } from "path";
2981
3526
  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"));
3527
+ const encPath = join10(dataDir, "denials.enc.json");
3528
+ const plainPath = join10(dataDir, "denials.json");
3529
+ if (existsSync8(encPath)) {
3530
+ const { blob } = JSON.parse(readFileSync10(encPath, "utf-8"));
2986
3531
  return JSON.parse(decrypt(blob)).records ?? [];
2987
3532
  }
2988
- if (existsSync7(plainPath)) {
2989
- return JSON.parse(readFileSync8(plainPath, "utf-8")).records ?? [];
3533
+ if (existsSync8(plainPath)) {
3534
+ return JSON.parse(readFileSync10(plainPath, "utf-8")).records ?? [];
2990
3535
  }
2991
3536
  return [];
2992
3537
  }
@@ -3000,74 +3545,6 @@ function selectDenials(records, opts = {}) {
3000
3545
  return { count, records: out };
3001
3546
  }
3002
3547
 
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
3548
  // src/index.ts
3072
3549
  (function migrateDataDir() {
3073
3550
  if (process.env.SUVEREN_DATA_DIR) return;
@@ -3085,6 +3562,7 @@ function resolveAsUrl(dataDir, flag) {
3085
3562
  })();
3086
3563
  var DATA_DIR = process.env.SUVEREN_DATA_DIR ?? join11(homedir7(), ".suveren");
3087
3564
  var SP_URL = resolveAsUrl(DATA_DIR);
3565
+ validatePinTlsForUrl(resolvePinTls(DATA_DIR), SP_URL);
3088
3566
  var port = parseInt(process.env.SUVEREN_CP_PORT ?? "3402", 10);
3089
3567
  var HAP_MODE = process.env.HAP_MODE ?? "personal";
3090
3568
  var UI_DIST = process.env.HAP_UI_DIST ?? join11(import.meta.dirname ?? __dirname, "../../ui/dist");
@@ -3099,7 +3577,7 @@ var vault = new Vault(DATA_DIR);
3099
3577
  vault.markBootLockedAsUrlChanged();
3100
3578
  }
3101
3579
  })();
3102
- var internalSecret2 = process.env.SUVEREN_INTERNAL_SECRET ?? randomBytes2(32).toString("hex");
3580
+ var internalSecret2 = process.env.SUVEREN_INTERNAL_SECRET ?? randomBytes3(32).toString("hex");
3103
3581
  setInternalSecret(internalSecret2);
3104
3582
  var app = express();
3105
3583
  var jsonParser = express.json();
@@ -3123,6 +3601,7 @@ function loginRateLimit(req, res, next) {
3123
3601
  }
3124
3602
  var lockExpiredSession = createSessionLock({ vault, port });
3125
3603
  var lockAsKeyMismatch = createAsKeyMismatchLock({ vault, port });
3604
+ var lockAsTlsMismatch = createAsTlsMismatchLock({ vault, port });
3126
3605
  var sessionExpiryScheduler = new SessionExpiryScheduler(
3127
3606
  () => vault.getSessionExpiresAt(),
3128
3607
  lockExpiredSession
@@ -3339,7 +3818,7 @@ app.get("/auth/oauth/:integrationId/health", authGuard, async (req, res) => {
3339
3818
  }
3340
3819
  });
3341
3820
  app.get("/events", requireAllowedHost, requireAuthQueryOrHeader(vault), createEventsHandler());
3342
- app.use("/internal", jsonParser, createInternalEventsRouter(() => internalSecret2, lockExpiredSession, lockAsKeyMismatch));
3821
+ app.use("/internal", jsonParser, createInternalEventsRouter(() => internalSecret2, lockExpiredSession, lockAsKeyMismatch, lockAsTlsMismatch));
3343
3822
  app.use("/vault", jsonParser, authGuard, createVaultRouter(vault));
3344
3823
  app.use("/ai", jsonParser, authGuard, createAIRouter(vault));
3345
3824
  app.use("/ai-prompts", jsonParser, authGuard, createAIPromptsRouter());
@@ -3348,7 +3827,7 @@ app.use("/autostart", jsonParser, authGuard, createAutostartRouter());
3348
3827
  app.use("/api/gateway-settings", jsonParser, authGuard, createGatewaySettingsRouter());
3349
3828
  app.use("/api/archived-mandates", jsonParser, authGuard, createArchivedMandatesRouter());
3350
3829
  app.use("/api/encrypt-intent", jsonParser, authGuard, createEncryptIntentRouter());
3351
- app.use("/api/evidence-export", authGuard, createEvidenceExportRouter(SP_URL, vault));
3830
+ app.use("/api/evidence-export", authGuard, createEvidenceExportRouter(SP_URL, vault, DATA_DIR, lockAsTlsMismatch));
3352
3831
  app.use("/api/evidence", authGuard, createEvidenceRouter());
3353
3832
  app.use("/api/decrypt-intent", jsonParser, authGuard, createDecryptIntentRouter(vault));
3354
3833
  app.use("/api/approved-intents", jsonParser, authGuard, createApprovedIntentsRouter(vault));
@@ -3489,11 +3968,18 @@ app.get("/skipped-commitments", authGuard, async (_req, res) => {
3489
3968
  });
3490
3969
  app.get("/as-pairing", authGuard, (_req, res) => {
3491
3970
  const pairing = readPairing(DATA_DIR);
3971
+ const paired = pairing !== null && pairing.asUrl === SP_URL;
3492
3972
  res.json({
3493
3973
  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
3974
+ paired,
3975
+ fingerprint: paired ? fingerprintOf(pairing.publicKeyHex) : null,
3976
+ pairedAt: paired ? pairing.pairedAt : null,
3977
+ // Opt-in TLS pinning (`config set pin-tls on`) — see as-tls-pin.ts.
3978
+ // `tlsPinFingerprint` uses the SAME display convention as the signing
3979
+ // key (formatPinFingerprint mirrors fingerprintOf's grouping), applied
3980
+ // to the already-computed SPKI digest rather than re-hashing a key.
3981
+ pinTlsEnabled: resolvePinTls(DATA_DIR),
3982
+ tlsPinFingerprint: paired && pairing.tlsSpkiPinHex ? formatPinFingerprint(pairing.tlsSpkiPinHex) : null
3497
3983
  });
3498
3984
  });
3499
3985
  app.get("/denials", authGuard, (req, res) => {
@@ -3562,11 +4048,19 @@ app.put("/agent-brief/context", jsonParser, authGuard, async (req, res) => {
3562
4048
  }
3563
4049
  });
3564
4050
  app.use("/api", authGuard);
4051
+ var pinnedProxyAgent = SP_URL.startsWith("https:") ? buildPinnedHttpsAgent(() => {
4052
+ const pin = readPairing(DATA_DIR);
4053
+ return {
4054
+ enforce: resolvePinTls(DATA_DIR),
4055
+ pinnedSpkiHex: pin && pin.asUrl === SP_URL ? pin.tlsSpkiPinHex : void 0
4056
+ };
4057
+ }) : void 0;
3565
4058
  app.use(
3566
4059
  createProxyMiddleware({
3567
4060
  target: SP_URL,
3568
4061
  changeOrigin: true,
3569
4062
  pathFilter: "/api",
4063
+ ...pinnedProxyAgent ? { agent: pinnedProxyAgent } : {},
3570
4064
  on: {
3571
4065
  proxyReq: (proxyReq) => {
3572
4066
  const cookie = vault.getSpCookie();
@@ -3574,6 +4068,21 @@ app.use(
3574
4068
  proxyReq.setHeader("Cookie", cookie);
3575
4069
  }
3576
4070
  },
4071
+ // A pin check failure means the connection never completed its TLS
4072
+ // handshake — nothing (not even this call's own cookie) reached the
4073
+ // network. Lock the gateway with the real reason, loudly; an ordinary
4074
+ // network error (AS outage) is NOT a pin failure (see isPinCheckError
4075
+ // / as-tls-pin.ts's module doc comment on outage misclassification)
4076
+ // and falls through to http-proxy-middleware's default error handling.
4077
+ error: (err, _req, res) => {
4078
+ if (!isPinCheckError(err)) return;
4079
+ console.error("[Control Plane] /api proxy: TLS pin mismatch \u2014 gateway LOCKED:", err.message);
4080
+ lockAsTlsMismatch();
4081
+ if (res && "writeHead" in res && !res.headersSent) {
4082
+ res.writeHead(502, { "Content-Type": "application/json" });
4083
+ res.end(JSON.stringify({ error: "as_tls_mismatch", message: err.message }));
4084
+ }
4085
+ },
3577
4086
  proxyRes: (proxyRes, req) => {
3578
4087
  const status = proxyRes.statusCode ?? 0;
3579
4088
  if (status === 401 && vault.isUnlocked()) {
@@ -3627,7 +4136,7 @@ function detectRunningVersion() {
3627
4136
  const bundlePkg = join11(dir, "..", "..", "package.json");
3628
4137
  if (existsSync9(bundlePkg)) {
3629
4138
  try {
3630
- const pkg = JSON.parse(readFileSync10(bundlePkg, "utf8"));
4139
+ const pkg = JSON.parse(readFileSync11(bundlePkg, "utf8"));
3631
4140
  if (pkg.name === "@suveren/gateway" && typeof pkg.version === "string") {
3632
4141
  return pkg.version;
3633
4142
  }