@camstack/system 1.2.427 → 1.2.429

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 (100) hide show
  1. package/dist/agent-https-names-CpUSuP4w.js +120 -0
  2. package/dist/agent-https-names-uetr4OQl.mjs +91 -0
  3. package/dist/auth/auth-audit-log.d.ts +1 -1
  4. package/dist/auth/device-ca-store.d.ts +57 -0
  5. package/dist/auth/device-ca.d.ts +75 -0
  6. package/dist/auth/device-credential-manager.d.ts +154 -0
  7. package/dist/auth/login-session-manager.d.ts +14 -2
  8. package/dist/auth/user-management-internal.d.ts +10 -1
  9. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  10. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  11. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  12. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  13. package/dist/builtins/alerts/alerts.addon.js +1 -1
  14. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  15. package/dist/builtins/autotrack/index.js +1 -1
  16. package/dist/builtins/autotrack/index.mjs +1 -1
  17. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  18. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  19. package/dist/builtins/calendars/calendars.addon.js +1 -1
  20. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  21. package/dist/builtins/camera-grid/index.js +1 -1
  22. package/dist/builtins/camera-grid/index.mjs +1 -1
  23. package/dist/builtins/composer/composer.addon.js +2 -2
  24. package/dist/builtins/composer/composer.addon.mjs +2 -2
  25. package/dist/builtins/console-logging/index.js +1 -1
  26. package/dist/builtins/console-logging/index.mjs +1 -1
  27. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  28. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  29. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  30. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  31. package/dist/builtins/hub-forwarder/index.js +1 -1
  32. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  33. package/dist/builtins/keychain/index.js +10 -6
  34. package/dist/builtins/keychain/index.mjs +8 -4
  35. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  36. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  37. package/dist/builtins/local-auth/local-auth.addon.d.ts +6 -0
  38. package/dist/builtins/local-auth/local-auth.addon.js +955 -47
  39. package/dist/builtins/local-auth/local-auth.addon.mjs +946 -41
  40. package/dist/builtins/local-network/local-network.addon.js +2 -2
  41. package/dist/builtins/local-network/local-network.addon.mjs +2 -2
  42. package/dist/builtins/loki-logging/index.js +1 -1
  43. package/dist/builtins/loki-logging/index.mjs +1 -1
  44. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  45. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  46. package/dist/builtins/platform-probe/index.js +1 -1
  47. package/dist/builtins/platform-probe/index.mjs +1 -1
  48. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  49. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  50. package/dist/builtins/snapshot/index.js +1 -1
  51. package/dist/builtins/snapshot/index.mjs +1 -1
  52. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  53. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  54. package/dist/builtins/sqlite-storage/index.js +1 -1
  55. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  56. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +3 -3
  57. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +3 -3
  58. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  59. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  60. package/dist/builtins/system-config/system-config.addon.d.ts +18 -0
  61. package/dist/builtins/system-config/system-config.addon.js +834 -18
  62. package/dist/builtins/system-config/system-config.addon.mjs +819 -19
  63. package/dist/builtins/winston-logging/index.js +1 -1
  64. package/dist/builtins/winston-logging/index.mjs +1 -1
  65. package/dist/{composition-sources-DvCGZEUI.js → composition-sources-C2VNX6tF.js} +1 -1
  66. package/dist/{composition-sources-BQNxXQZG.mjs → composition-sources-SYtYuAgh.mjs} +1 -1
  67. package/dist/{constants-CR4If_lP.mjs → constants-CKuWoWvc.mjs} +1 -1
  68. package/dist/{constants-CCSJGnjs.js → constants-RI-hGP5S.js} +1 -1
  69. package/dist/device-ca-BWfdDB3k.js +462 -0
  70. package/dist/device-ca-R7DKKZL6.mjs +355 -0
  71. package/dist/{dist-BQG3QFg7.js → dist-Bgy_AmwX.js} +554 -0
  72. package/dist/{dist-DHSJV4Qc.mjs → dist-CSSfTM18.mjs} +507 -1
  73. package/dist/file-host-worker.js +1 -1
  74. package/dist/file-host-worker.mjs +1 -1
  75. package/dist/index.d.ts +4 -1
  76. package/dist/index.js +59 -12
  77. package/dist/index.mjs +10 -9
  78. package/dist/kernel/index.d.ts +2 -2
  79. package/dist/kernel/moleculer/mesh-mtls/index.d.ts +4 -1
  80. package/dist/kernel/moleculer/mesh-mtls/mesh-certificate-registry.d.ts +91 -0
  81. package/dist/kernel/moleculer/mesh-mtls/mesh-enroll.d.ts +4 -0
  82. package/dist/kernel/moleculer/mesh-mtls/mesh-peer-transport.d.ts +23 -0
  83. package/dist/kernel/moleculer/mesh-mtls/mesh-renewal.d.ts +126 -0
  84. package/dist/kernel/moleculer/mesh-mtls/mesh-tls-gate.d.ts +40 -0
  85. package/dist/kernel/moleculer/mesh-mtls/tls-tcp-transporter.d.ts +15 -0
  86. package/dist/{restore-archive-DH1Pxq_1.mjs → restore-archive-AIwMHO5_.mjs} +1 -1
  87. package/dist/{restore-archive-Dccm8lkA.js → restore-archive-BYUEMsog.js} +1 -1
  88. package/dist/{retired-settings-keys-BfGaSeQI.js → retired-settings-keys-BTPkRLTS.js} +1 -1
  89. package/dist/{retired-settings-keys-CPz7zpza.mjs → retired-settings-keys-H0rYXT27.mjs} +1 -1
  90. package/dist/tls/agent-https-names.d.ts +27 -0
  91. package/dist/tls/agent-https.d.ts +111 -0
  92. package/dist/tls/cert-issue.d.ts +7 -0
  93. package/dist/tls/index.d.ts +3 -0
  94. package/dist/{tls-BgImdxCO.js → tls-C5IYg3V5.js} +406 -0
  95. package/dist/{tls-CaOLZ09W.mjs → tls-CpcGkeJV.mjs} +314 -4
  96. package/dist/{wal-maintenance-DItkuOvu.js → wal-maintenance-BSGsxKW7.js} +0 -0
  97. package/dist/{wal-maintenance-Drqhcjit.mjs → wal-maintenance-DW0FfGV7.mjs} +0 -0
  98. package/package.json +4 -1
  99. package/dist/jwt-signing-key-BZm055IP.js +0 -174
  100. package/dist/jwt-signing-key-DL-qk1SM.mjs +0 -127
@@ -1,15 +1,15 @@
1
1
  import { i as __require, o as __toESM, t as __commonJSMin } from "../../chunk-CNf5ZN-e.mjs";
2
- import { Bn as userManagementCapability, J as authProviderCapability, R as ScopedTokenSchema, U as UserRecordSchema, lr as BaseAddon, r as ApiKeyRecordSchema } from "../../dist-DHSJV4Qc.mjs";
2
+ import { Et as deviceCredentialsCapability, H as ScopedTokenSchema, Jn as userManagementCapability, Q as authProviderCapability, S as DEVICE_NONCE_TTL_MS, Vt as formatDeviceRevoker, _r as BaseAddon, it as buildDeviceSignatureMessage, q as UserRecordSchema, r as ApiKeyRecordSchema, rt as buildDeviceEnrollmentLink, x as DEVICE_ENROLLMENT_TTL_MS } from "../../dist-CSSfTM18.mjs";
3
3
  import { t as require_safe_buffer } from "../../safe-buffer-B1OjL1I9.mjs";
4
4
  import { t as require_ms } from "../../ms-CgH2OFlD.mjs";
5
- import { a as loadJwtSigningKey, i as getProcessJwtSigningKeyState, o as processJwtSigningKey, s as setProcessJwtSigningKey, t as JWT_SIGNING_KEY_REF } from "../../jwt-signing-key-DL-qk1SM.mjs";
5
+ import { _ as setProcessJwtSigningKey, a as importDevicePublicKey, c as rebuildDeviceCaCertificate, d as JWT_SIGNING_KEY_REF, g as processJwtSigningKey, h as loadJwtSigningKey, i as generateDeviceCa, l as verifyDeviceLeaf, m as getProcessJwtSigningKeyState, n as DEVICE_CA_KEY_REF, o as issueDeviceLeaf, r as DEVICE_CA_SETTINGS_SECTION, s as loadDeviceCa, t as DEVICE_CA_FIELD, u as verifyDeviceSignature } from "../../device-ca-R7DKKZL6.mjs";
6
6
  import * as crypto$1 from "node:crypto";
7
- import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
7
+ import { X509Certificate, createHash, randomUUID, timingSafeEqual } from "node:crypto";
8
8
  import nodeCrypto from "crypto";
9
9
  import * as path$1 from "node:path";
10
10
  import * as fs from "node:fs";
11
11
  //#region src/auth/parse-record.ts
12
- function parseRecord$1(kind, schema, data) {
12
+ function parseRecord$2(kind, schema, data) {
13
13
  try {
14
14
  return schema.parse(data);
15
15
  } catch (err) {
@@ -31,7 +31,7 @@ function parseRecord$1(kind, schema, data) {
31
31
  */
32
32
  var API_KEYS_COLLECTION$1 = "api_keys";
33
33
  function parseApiKey(data) {
34
- return parseRecord$1("api-key", ApiKeyRecordSchema, data);
34
+ return parseRecord$2("api-key", ApiKeyRecordSchema, data);
35
35
  }
36
36
  var ApiKeyManager = class {
37
37
  storageAccess;
@@ -153,7 +153,7 @@ function genSalt(rounds, seed_length, callback) {
153
153
  * @returns {!Promise} If `callback` has been omitted
154
154
  * @throws {Error} If `callback` is present but not a function
155
155
  */
156
- function hash$1(password, salt, callback, progressCallback) {
156
+ function hash$2(password, salt, callback, progressCallback) {
157
157
  function _async(callback) {
158
158
  if (typeof password === "string" && typeof salt === "number") genSalt(salt, function(err, salt) {
159
159
  _hash(password, salt, callback, progressCallback);
@@ -206,7 +206,7 @@ function compare(password, hashValue, callback, progressCallback) {
206
206
  nextTick(callback.bind(this, null, false));
207
207
  return;
208
208
  }
209
- hash$1(password, hashValue.substring(0, 29), function(err, comp) {
209
+ hash$2(password, hashValue.substring(0, 29), function(err, comp) {
210
210
  if (err) callback(err);
211
211
  else callback(null, safeStringCompare(comp, hashValue));
212
212
  }, progressCallback);
@@ -6077,7 +6077,7 @@ var AuthManager = class {
6077
6077
  return import_jsonwebtoken.verify(token, this.jwtSecret);
6078
6078
  }
6079
6079
  async hashPassword(password) {
6080
- return hash$1(password, 10);
6080
+ return hash$2(password, 10);
6081
6081
  }
6082
6082
  async comparePassword(password, hash) {
6083
6083
  return compare(password, hash);
@@ -6272,7 +6272,7 @@ var AuthManager = class {
6272
6272
  var TOKENS_COLLECTION = "scoped_tokens";
6273
6273
  var TOKEN_PREFIX = "cst_";
6274
6274
  function parseToken(data) {
6275
- return parseRecord$1("scoped-token", ScopedTokenSchema, data);
6275
+ return parseRecord$2("scoped-token", ScopedTokenSchema, data);
6276
6276
  }
6277
6277
  /**
6278
6278
  * Manages scoped API tokens with restricted addon/route/capability access.
@@ -6555,6 +6555,10 @@ var LOGIN_SESSIONS_COLUMNS = [
6555
6555
  {
6556
6556
  name: "ip",
6557
6557
  type: "TEXT"
6558
+ },
6559
+ {
6560
+ name: "credentialId",
6561
+ type: "TEXT"
6558
6562
  }
6559
6563
  ];
6560
6564
  var LOGIN_SESSION_CUTOFFS_COLUMNS = [{
@@ -6572,7 +6576,8 @@ var METHODS = [
6572
6576
  "passkey",
6573
6577
  "sso",
6574
6578
  "handoff",
6575
- "exchange"
6579
+ "exchange",
6580
+ "device-certificate"
6576
6581
  ];
6577
6582
  var REFRESH_TOKEN_PREFIX = "csr_";
6578
6583
  /**
@@ -6588,20 +6593,20 @@ var MAX_CLIENT_LABEL = 200;
6588
6593
  function hashToken(token) {
6589
6594
  return crypto$1.createHash("sha256").update(token).digest("hex");
6590
6595
  }
6591
- function str(data, key) {
6596
+ function str$1(data, key) {
6592
6597
  const value = data[key];
6593
6598
  return typeof value === "string" && value.length > 0 ? value : null;
6594
6599
  }
6595
- function num(data, key) {
6600
+ function num$1(data, key) {
6596
6601
  const value = data[key];
6597
6602
  return typeof value === "number" && Number.isFinite(value) ? value : null;
6598
6603
  }
6599
6604
  function parseSession$1(data) {
6600
- const id = str(data, "id");
6601
- const userId = str(data, "userId");
6602
- const createdAt = num(data, "createdAt");
6603
- const lastUsedAt = num(data, "lastUsedAt");
6604
- const expiresAt = num(data, "expiresAt");
6605
+ const id = str$1(data, "id");
6606
+ const userId = str$1(data, "userId");
6607
+ const createdAt = num$1(data, "createdAt");
6608
+ const lastUsedAt = num$1(data, "lastUsedAt");
6609
+ const expiresAt = num$1(data, "expiresAt");
6605
6610
  const method = METHODS.find((m) => m === data["method"]);
6606
6611
  if (id === null || userId === null || createdAt === null || expiresAt === null) return null;
6607
6612
  return {
@@ -6612,13 +6617,14 @@ function parseSession$1(data) {
6612
6617
  createdAt,
6613
6618
  lastUsedAt: lastUsedAt ?? createdAt,
6614
6619
  expiresAt,
6615
- revokedAt: num(data, "revokedAt"),
6616
- revokedReason: str(data, "revokedReason"),
6617
- refreshHash: str(data, "refreshHash"),
6618
- previousRefreshHash: str(data, "previousRefreshHash"),
6619
- rotatedAt: num(data, "rotatedAt"),
6620
- client: str(data, "client"),
6621
- ip: str(data, "ip")
6620
+ revokedAt: num$1(data, "revokedAt"),
6621
+ revokedReason: str$1(data, "revokedReason"),
6622
+ refreshHash: str$1(data, "refreshHash"),
6623
+ previousRefreshHash: str$1(data, "previousRefreshHash"),
6624
+ rotatedAt: num$1(data, "rotatedAt"),
6625
+ client: str$1(data, "client"),
6626
+ ip: str$1(data, "ip"),
6627
+ credentialId: str$1(data, "credentialId")
6622
6628
  };
6623
6629
  }
6624
6630
  function toLoginSessionSummary(record) {
@@ -6647,8 +6653,8 @@ var LoginSessionManager = class {
6647
6653
  }
6648
6654
  const cutoffs = await this.store.query.query({ collection: LOGIN_SESSION_CUTOFFS_COLLECTION });
6649
6655
  for (const row of cutoffs) {
6650
- const userId = str(row.data, "userId");
6651
- const notBefore = num(row.data, "notBefore");
6656
+ const userId = str$1(row.data, "userId");
6657
+ const notBefore = num$1(row.data, "notBefore");
6652
6658
  if (userId !== null && notBefore !== null) this.cutoffs.set(userId, notBefore);
6653
6659
  }
6654
6660
  }
@@ -6669,7 +6675,8 @@ var LoginSessionManager = class {
6669
6675
  previousRefreshHash: null,
6670
6676
  rotatedAt: null,
6671
6677
  client: input.client === void 0 ? null : input.client.slice(0, MAX_CLIENT_LABEL),
6672
- ip: input.ip ?? null
6678
+ ip: input.ip ?? null,
6679
+ credentialId: input.credentialId ?? null
6673
6680
  };
6674
6681
  await this.store.insert.mutate({
6675
6682
  collection: LOGIN_SESSIONS_COLLECTION,
@@ -6699,6 +6706,16 @@ var LoginSessionManager = class {
6699
6706
  legacyCutoff(userId) {
6700
6707
  return this.cutoffs.get(userId) ?? 0;
6701
6708
  }
6709
+ /**
6710
+ * The session a refresh token belongs to, WITHOUT rotating it - so the
6711
+ * refresh path can judge the session (its device credential) before it
6712
+ * spends the token. `null` when the token names no session.
6713
+ */
6714
+ peekByRefreshToken(refreshToken) {
6715
+ if (!refreshToken.startsWith("csr_")) return null;
6716
+ const sessionId = this.byRefreshHash.get(hashToken(refreshToken));
6717
+ return sessionId === void 0 ? null : this.sessions.get(sessionId) ?? null;
6718
+ }
6702
6719
  /** Swap a refresh token for a rotated one, sliding the session's expiry to now + `lifetimeMs`. */
6703
6720
  async refresh(refreshToken, lifetimeMs) {
6704
6721
  if (!refreshToken.startsWith("csr_")) return {
@@ -6802,6 +6819,23 @@ var LoginSessionManager = class {
6802
6819
  for (const session of ended) await this.persist(session);
6803
6820
  return ended.length;
6804
6821
  }
6822
+ /** End every live session opened by device credential `credentialId`. Returns how many. */
6823
+ async revokeByCredential(credentialId, reason = "credential-revoked") {
6824
+ const at = this.now();
6825
+ const ended = [];
6826
+ for (const session of this.sessions.values()) {
6827
+ if (session.credentialId !== credentialId || session.revokedAt !== null) continue;
6828
+ const updated = {
6829
+ ...session,
6830
+ revokedAt: at,
6831
+ revokedReason: reason
6832
+ };
6833
+ this.replace(session, updated);
6834
+ ended.push(updated);
6835
+ }
6836
+ for (const session of ended) await this.persist(session);
6837
+ return ended.length;
6838
+ }
6805
6839
  /** `userId`'s live sessions, newest first. */
6806
6840
  listForUser(userId) {
6807
6841
  return this.listActive().filter((s) => s.userId === userId);
@@ -6955,7 +6989,7 @@ var ADMIN_TOKEN_PREFIX = "csa_";
6955
6989
  var ADMIN_TOKEN_MAX_LIFETIME_MS = 90 * 864e5;
6956
6990
  var DEAD_RETENTION_MS = 30 * 864e5;
6957
6991
  var TOUCH_EVERY_MS = 6e4;
6958
- function hash(token) {
6992
+ function hash$1(token) {
6959
6993
  return crypto$1.createHash("sha256").update(token).digest("hex");
6960
6994
  }
6961
6995
  function parse(data) {
@@ -6995,7 +7029,7 @@ var AdminTokenManager = class {
6995
7029
  id: crypto$1.randomUUID(),
6996
7030
  createdBy: input.createdBy,
6997
7031
  name,
6998
- tokenHash: hash(token),
7032
+ tokenHash: hash$1(token),
6999
7033
  tokenPrefix: token.slice(0, 12),
7000
7034
  createdAt: at,
7001
7035
  expiresAt: input.expiresAt,
@@ -7019,7 +7053,7 @@ var AdminTokenManager = class {
7019
7053
  if (!token.startsWith("csa_")) return null;
7020
7054
  const first = (await this.store.query.query({
7021
7055
  collection: ADMIN_TOKENS_COLLECTION,
7022
- filter: { where: { tokenHash: hash(token) } }
7056
+ filter: { where: { tokenHash: hash$1(token) } }
7023
7057
  }))[0];
7024
7058
  const stored = first === void 0 ? null : parse(first.data);
7025
7059
  if (stored === null || stored.revokedAt !== null) return null;
@@ -7080,6 +7114,678 @@ var AdminTokenManager = class {
7080
7114
  }
7081
7115
  };
7082
7116
  //#endregion
7117
+ //#region src/auth/device-ca-store.ts
7118
+ function hasCertificate(value) {
7119
+ return typeof value === "string" && value.includes("BEGIN CERTIFICATE");
7120
+ }
7121
+ var DeviceCaStore = class {
7122
+ deps;
7123
+ loaded = null;
7124
+ creating = null;
7125
+ lastReported = null;
7126
+ constructor(deps) {
7127
+ this.deps = deps;
7128
+ }
7129
+ /** The CA when one exists (completing an interrupted creation). Never creates a NEW one. */
7130
+ async lookup() {
7131
+ if (this.loaded !== null) return {
7132
+ state: "ready",
7133
+ ca: this.loaded
7134
+ };
7135
+ const secrets = this.deps.secrets;
7136
+ if (secrets === null) return {
7137
+ state: "unavailable",
7138
+ reason: "no keychain door in this process"
7139
+ };
7140
+ let entry;
7141
+ try {
7142
+ entry = (await secrets.resolve([DEVICE_CA_KEY_REF]))[DEVICE_CA_KEY_REF];
7143
+ } catch (err) {
7144
+ return {
7145
+ state: "unavailable",
7146
+ reason: err instanceof Error ? err.message : String(err)
7147
+ };
7148
+ }
7149
+ if (entry === void 0) return {
7150
+ state: "unavailable",
7151
+ reason: "no-answer"
7152
+ };
7153
+ if (entry.kind === "unavailable") return {
7154
+ state: "unavailable",
7155
+ reason: entry.reason
7156
+ };
7157
+ if (entry.kind === "forbidden") return {
7158
+ state: "unavailable",
7159
+ reason: "forbidden"
7160
+ };
7161
+ let section;
7162
+ try {
7163
+ section = await this.deps.readSection(DEVICE_CA_SETTINGS_SECTION);
7164
+ } catch (err) {
7165
+ return {
7166
+ state: "unavailable",
7167
+ reason: `settings unreadable: ${err instanceof Error ? err.message : String(err)}`
7168
+ };
7169
+ }
7170
+ const certPem = section[DEVICE_CA_FIELD];
7171
+ if (entry.kind === "missing") {
7172
+ if (!hasCertificate(certPem)) return { state: "absent" };
7173
+ return this.report("the device CA key is missing from the keychain but cluster.deviceCa is present - not creating a new CA (it would orphan every enrolled device); restore the key (secret://system/global/deviceCaKey) or remove cluster.deviceCa knowingly");
7174
+ }
7175
+ try {
7176
+ if (!hasCertificate(certPem)) {
7177
+ const rebuilt = await rebuildDeviceCaCertificate(entry.value);
7178
+ await this.deps.writeSection(DEVICE_CA_SETTINGS_SECTION, { [DEVICE_CA_FIELD]: rebuilt.certPem });
7179
+ this.deps.logger?.warn("Device CA certificate rebuilt from the stored key", { meta: { reason: "cluster.deviceCa was missing next to the key" } });
7180
+ this.loaded = rebuilt;
7181
+ return {
7182
+ state: "ready",
7183
+ ca: rebuilt
7184
+ };
7185
+ }
7186
+ this.loaded = await loadDeviceCa(certPem, entry.value);
7187
+ return {
7188
+ state: "ready",
7189
+ ca: this.loaded
7190
+ };
7191
+ } catch (err) {
7192
+ return {
7193
+ state: "unavailable",
7194
+ reason: err instanceof Error ? err.message : String(err)
7195
+ };
7196
+ }
7197
+ }
7198
+ /** The CA, created now if neither half exists. Throws on any inconsistent or unreadable state. */
7199
+ async ensure() {
7200
+ const found = await this.lookup();
7201
+ if (found.state === "ready") return found.ca;
7202
+ if (found.state === "unavailable") throw new Error(`device CA unavailable: ${found.reason}`);
7203
+ if (this.creating === null) this.creating = this.create().finally(() => {
7204
+ this.creating = null;
7205
+ });
7206
+ return this.creating;
7207
+ }
7208
+ report(reason) {
7209
+ if (this.lastReported !== reason) {
7210
+ this.lastReported = reason;
7211
+ this.deps.logger?.error(`Device CA is inconsistent: ${reason}`);
7212
+ }
7213
+ return {
7214
+ state: "unavailable",
7215
+ reason
7216
+ };
7217
+ }
7218
+ async create() {
7219
+ const secrets = this.deps.secrets;
7220
+ if (secrets === null) throw new Error("device CA unavailable: no keychain door");
7221
+ const generated = await generateDeviceCa();
7222
+ await secrets.put(DEVICE_CA_KEY_REF, generated.keyPem);
7223
+ await this.deps.writeSection(DEVICE_CA_SETTINGS_SECTION, { [DEVICE_CA_FIELD]: generated.certPem });
7224
+ this.loaded = await loadDeviceCa(generated.certPem, generated.keyPem);
7225
+ return this.loaded;
7226
+ }
7227
+ };
7228
+ //#endregion
7229
+ //#region src/auth/device-credential-manager.ts
7230
+ /**
7231
+ * Device credentials (D855): the registry of enrolled devices, the one-shot
7232
+ * enrollment codes, the login nonces and the certificate checks.
7233
+ *
7234
+ * Hub-owned and persisted in `device_credentials` (the settings store);
7235
+ * agents persist nothing. Enrollment codes and nonces live in memory only —
7236
+ * both die in minutes and a hub restart simply asks the user to start again.
7237
+ *
7238
+ * Every refusal is logged with its reason and the credential/user it concerns
7239
+ * (`tags: { credentialId | userId }`) — never the code, the nonce or a
7240
+ * signature.
7241
+ */
7242
+ /**
7243
+ * @durable class=config owner=local-auth
7244
+ * write="an enrollment completes (QR or self) inserts a row; a renewal rewrites
7245
+ * fingerprint/expiry; every accepted certificate login rewrites lastUsedAt/From;
7246
+ * an admin revokes or renames"
7247
+ * retention="a revoked or long-expired row is kept 90 days for the audit question
7248
+ * 'which device was that', then the daily reap deletes it; an active row is
7249
+ * never deleted"
7250
+ */
7251
+ var DEVICE_CREDENTIALS_COLLECTION = "device_credentials";
7252
+ var DEVICE_CREDENTIALS_COLUMNS = [
7253
+ {
7254
+ name: "id",
7255
+ type: "TEXT",
7256
+ primaryKey: true,
7257
+ notNull: true
7258
+ },
7259
+ {
7260
+ name: "userId",
7261
+ type: "TEXT",
7262
+ notNull: true
7263
+ },
7264
+ {
7265
+ name: "deviceName",
7266
+ type: "TEXT",
7267
+ notNull: true
7268
+ },
7269
+ {
7270
+ name: "platform",
7271
+ type: "TEXT",
7272
+ notNull: true
7273
+ },
7274
+ {
7275
+ name: "enrolledVia",
7276
+ type: "TEXT",
7277
+ notNull: true
7278
+ },
7279
+ {
7280
+ name: "fingerprint",
7281
+ type: "TEXT",
7282
+ notNull: true
7283
+ },
7284
+ {
7285
+ name: "serial",
7286
+ type: "TEXT",
7287
+ notNull: true
7288
+ },
7289
+ {
7290
+ name: "previousFingerprint",
7291
+ type: "TEXT"
7292
+ },
7293
+ {
7294
+ name: "renewedAt",
7295
+ type: "INTEGER"
7296
+ },
7297
+ {
7298
+ name: "publicKey",
7299
+ type: "TEXT",
7300
+ notNull: true
7301
+ },
7302
+ {
7303
+ name: "issuedAt",
7304
+ type: "INTEGER",
7305
+ notNull: true
7306
+ },
7307
+ {
7308
+ name: "expiresAt",
7309
+ type: "INTEGER",
7310
+ notNull: true
7311
+ },
7312
+ {
7313
+ name: "lastUsedAt",
7314
+ type: "INTEGER"
7315
+ },
7316
+ {
7317
+ name: "lastUsedFrom",
7318
+ type: "TEXT"
7319
+ },
7320
+ {
7321
+ name: "revokedAt",
7322
+ type: "INTEGER"
7323
+ },
7324
+ {
7325
+ name: "revokedBy",
7326
+ type: "TEXT"
7327
+ }
7328
+ ];
7329
+ var MAX_PENDING = 2e3;
7330
+ /**
7331
+ * Outstanding login nonces are bounded PER CLIENT (a partition = the client
7332
+ * address): one flooder evicts only its own oldest nonces, never another
7333
+ * client's. {@link MAX_NONCES_TOTAL} is a memory ceiling that only a flood from
7334
+ * hundreds of distinct addresses can reach.
7335
+ */
7336
+ var MAX_NONCES_PER_PARTITION = 50;
7337
+ var MAX_NONCES_TOTAL = 2e4;
7338
+ /**
7339
+ * After a renewal the PREVIOUS leaf still logs in for this long, so a device
7340
+ * that crashed between the hub's answer and its own write is not locked out;
7341
+ * after it, the old leaf is `superseded` (not definitive: the app holds the
7342
+ * newest certificate, so seeing it means the device lost the new one).
7343
+ */
7344
+ var PREVIOUS_LEAF_GRACE_MS = 24 * 36e5;
7345
+ var REAP_RETENTION_MS = 90 * 864e5;
7346
+ function hash(value) {
7347
+ return crypto$1.createHash("sha256").update(value).digest("hex");
7348
+ }
7349
+ function str(data, key) {
7350
+ const v = data[key];
7351
+ return typeof v === "string" && v.length > 0 ? v : null;
7352
+ }
7353
+ function num(data, key) {
7354
+ const v = data[key];
7355
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
7356
+ }
7357
+ var PLATFORMS = [
7358
+ "ios",
7359
+ "android",
7360
+ "web",
7361
+ "other"
7362
+ ];
7363
+ function parseRecord$1(data) {
7364
+ const id = str(data, "id");
7365
+ const userId = str(data, "userId");
7366
+ const deviceName = str(data, "deviceName");
7367
+ const fingerprint = str(data, "fingerprint");
7368
+ const serial = str(data, "serial");
7369
+ const publicKey = str(data, "publicKey");
7370
+ const issuedAt = num(data, "issuedAt");
7371
+ const expiresAt = num(data, "expiresAt");
7372
+ if (id === null || userId === null || deviceName === null || fingerprint === null || serial === null || publicKey === null || issuedAt === null || expiresAt === null) return null;
7373
+ return {
7374
+ id,
7375
+ userId,
7376
+ deviceName,
7377
+ platform: PLATFORMS.find((p) => p === data["platform"]) ?? "other",
7378
+ enrolledVia: data["enrolledVia"] === "self" ? "self" : "qr",
7379
+ fingerprint,
7380
+ serial,
7381
+ previousFingerprint: str(data, "previousFingerprint"),
7382
+ renewedAt: num(data, "renewedAt"),
7383
+ publicKey,
7384
+ issuedAt,
7385
+ expiresAt,
7386
+ lastUsedAt: num(data, "lastUsedAt"),
7387
+ lastUsedFrom: str(data, "lastUsedFrom"),
7388
+ revokedAt: num(data, "revokedAt"),
7389
+ revokedBy: str(data, "revokedBy")
7390
+ };
7391
+ }
7392
+ var DeviceCredentialManager = class {
7393
+ store;
7394
+ deps;
7395
+ records = /* @__PURE__ */ new Map();
7396
+ pendingEnrollments = /* @__PURE__ */ new Map();
7397
+ usedCodes = /* @__PURE__ */ new Map();
7398
+ nonces = /* @__PURE__ */ new Map();
7399
+ now;
7400
+ constructor(store, deps) {
7401
+ this.store = store;
7402
+ this.deps = deps;
7403
+ this.now = deps.now ?? Date.now;
7404
+ }
7405
+ async load() {
7406
+ const rows = await this.store.query.query({ collection: DEVICE_CREDENTIALS_COLLECTION });
7407
+ for (const row of rows) {
7408
+ const record = parseRecord$1(row.data);
7409
+ if (record !== null) this.records.set(record.id, record);
7410
+ }
7411
+ }
7412
+ /** A one-shot code for `userId` (admin path). Returned once; only its hash is kept. */
7413
+ startEnrollment(input) {
7414
+ this.prune();
7415
+ if (this.pendingEnrollments.size >= MAX_PENDING) throw new Error("Too many pending device enrollments — try again shortly");
7416
+ const code = crypto$1.randomBytes(24).toString("base64url");
7417
+ const expiresAt = this.now() + DEVICE_ENROLLMENT_TTL_MS;
7418
+ this.pendingEnrollments.set(hash(code), {
7419
+ userId: input.userId,
7420
+ deviceName: input.deviceName,
7421
+ createdBy: input.createdBy,
7422
+ expiresAt
7423
+ });
7424
+ return {
7425
+ code,
7426
+ expiresAt
7427
+ };
7428
+ }
7429
+ /** Redeem a code with the device's public key (public procedure). */
7430
+ async completeEnrollment(input) {
7431
+ const key = hash(input.code);
7432
+ const pending = this.pendingEnrollments.get(key);
7433
+ if (pending === void 0) {
7434
+ const wasUsed = this.usedCodes.has(key);
7435
+ return this.refuse("enroll", "unknown-or-used-code", { detail: wasUsed ? "code already redeemed" : "code unknown" });
7436
+ }
7437
+ this.pendingEnrollments.delete(key);
7438
+ this.usedCodes.set(key, pending.expiresAt);
7439
+ if (this.now() > pending.expiresAt) return this.refuse("enroll", "expired-code", { userId: pending.userId });
7440
+ return this.issueNew({
7441
+ userId: pending.userId,
7442
+ deviceName: input.deviceName ?? pending.deviceName,
7443
+ platform: input.platform,
7444
+ publicKeySpki: input.publicKeySpki,
7445
+ enrolledVia: "qr"
7446
+ });
7447
+ }
7448
+ /** A signed-in user's own device (the caller has already proved a step-up). */
7449
+ async enrollSelf(input) {
7450
+ if (!await this.deps.selfEnrollmentAllowed()) return this.refuse("enroll-self", "self-enrollment-disabled", { userId: input.userId });
7451
+ return this.issueNew({
7452
+ ...input,
7453
+ enrolledVia: "self"
7454
+ });
7455
+ }
7456
+ async issueNew(input) {
7457
+ const user = await this.deps.findUser(input.userId);
7458
+ if (user === null) return this.refuse("enroll", "user-gone", { userId: input.userId });
7459
+ const spki = Buffer.from(input.publicKeySpki, "base64url");
7460
+ try {
7461
+ await importDevicePublicKey(spki);
7462
+ } catch {
7463
+ return this.refuse("enroll", "bad-public-key", { userId: input.userId });
7464
+ }
7465
+ let ca;
7466
+ try {
7467
+ ca = await this.deps.ca.ensure();
7468
+ } catch (error) {
7469
+ this.deps.logger?.error("Device enrollment refused: the device CA is unavailable", {
7470
+ tags: { userId: input.userId },
7471
+ meta: { error: error instanceof Error ? error.message : String(error) }
7472
+ });
7473
+ return {
7474
+ ok: false,
7475
+ reason: "ca-unavailable"
7476
+ };
7477
+ }
7478
+ const id = crypto$1.randomUUID();
7479
+ const leaf = await issueDeviceLeaf(ca, {
7480
+ credentialId: id,
7481
+ userId: input.userId,
7482
+ publicKeySpki: spki,
7483
+ now: new Date(this.now())
7484
+ });
7485
+ const record = {
7486
+ id,
7487
+ userId: input.userId,
7488
+ deviceName: input.deviceName,
7489
+ platform: input.platform,
7490
+ enrolledVia: input.enrolledVia,
7491
+ fingerprint: leaf.fingerprint,
7492
+ serial: leaf.serialNumber,
7493
+ previousFingerprint: null,
7494
+ renewedAt: null,
7495
+ publicKey: input.publicKeySpki,
7496
+ issuedAt: leaf.notBefore.getTime(),
7497
+ expiresAt: leaf.notAfter.getTime(),
7498
+ lastUsedAt: null,
7499
+ lastUsedFrom: null,
7500
+ revokedAt: null,
7501
+ revokedBy: null
7502
+ };
7503
+ await this.store.insert.mutate({
7504
+ collection: DEVICE_CREDENTIALS_COLLECTION,
7505
+ record: {
7506
+ id,
7507
+ data: { ...record }
7508
+ }
7509
+ });
7510
+ this.records.set(id, record);
7511
+ this.deps.logger?.info("Device credential enrolled", {
7512
+ tags: {
7513
+ credentialId: id,
7514
+ userId: input.userId
7515
+ },
7516
+ meta: {
7517
+ via: input.enrolledVia,
7518
+ platform: input.platform
7519
+ }
7520
+ });
7521
+ return this.issued(record, leaf, ca, user.username);
7522
+ }
7523
+ issued(record, leaf, ca, username) {
7524
+ return {
7525
+ ok: true,
7526
+ credentialId: record.id,
7527
+ userId: record.userId,
7528
+ username,
7529
+ certPem: leaf.certPem,
7530
+ chainPem: ca.certPem,
7531
+ notAfter: leaf.notAfter.getTime()
7532
+ };
7533
+ }
7534
+ /** A single-use nonce bound to `origin`. */
7535
+ issueNonce(origin, partition = "unknown") {
7536
+ this.prune();
7537
+ const own = [...this.nonces.entries()].filter(([, v]) => v.partition === partition);
7538
+ for (const [key] of own.slice(0, Math.max(0, own.length - MAX_NONCES_PER_PARTITION + 1))) this.nonces.delete(key);
7539
+ if (this.nonces.size >= MAX_NONCES_TOTAL) {
7540
+ const oldest = this.nonces.keys().next();
7541
+ if (oldest.done !== true) this.nonces.delete(oldest.value);
7542
+ }
7543
+ const nonce = crypto$1.randomBytes(32).toString("base64url");
7544
+ const expiresAt = this.now() + DEVICE_NONCE_TTL_MS;
7545
+ this.nonces.set(nonce, {
7546
+ origin,
7547
+ expiresAt,
7548
+ partition
7549
+ });
7550
+ return {
7551
+ nonce,
7552
+ expiresAt
7553
+ };
7554
+ }
7555
+ /**
7556
+ * Check a presented certificate + signature. Consumes the nonce on the first
7557
+ * attempt. On success the credential's lastUsed is stamped.
7558
+ */
7559
+ async authenticate(input) {
7560
+ const bound = this.nonces.get(input.nonce);
7561
+ if (bound === void 0) return this.refuse(input.purpose, "unknown-nonce", {});
7562
+ this.nonces.delete(input.nonce);
7563
+ if (this.now() > bound.expiresAt) return this.refuse(input.purpose, "expired-nonce", {});
7564
+ const caState = await this.deps.ca.lookup();
7565
+ if (caState.state !== "ready") return this.refuse(input.purpose, "no-device-ca", {});
7566
+ const verdict = await verifyDeviceLeaf(caState.ca.certPem, input.certPem, new Date(this.now()));
7567
+ if (!verdict.ok) return this.refuse(input.purpose, verdict.reason, {});
7568
+ const message = buildDeviceSignatureMessage({
7569
+ purpose: input.purpose,
7570
+ origin: bound.origin,
7571
+ nonce: input.nonce
7572
+ });
7573
+ if (!verifyDeviceSignature(verdict.publicKeySpki, message, input.signature)) return this.refuse(input.purpose, "bad-signature", { credentialId: verdict.credentialId });
7574
+ const record = this.records.get(verdict.credentialId);
7575
+ if (record === void 0) return this.refuse(input.purpose, "unknown-credential", { credentialId: verdict.credentialId });
7576
+ const tags = {
7577
+ credentialId: record.id,
7578
+ userId: record.userId
7579
+ };
7580
+ if (record.revokedAt !== null) return this.refuse(input.purpose, "revoked", tags);
7581
+ if (verdict.userId !== null && verdict.userId !== record.userId) return this.refuse(input.purpose, "user-mismatch", tags);
7582
+ if (!verdict.publicKeySpki.equals(Buffer.from(record.publicKey, "base64url"))) return this.refuse(input.purpose, "key-mismatch", tags);
7583
+ if (verdict.fingerprint !== record.fingerprint) {
7584
+ if (!(record.previousFingerprint !== null && verdict.fingerprint === record.previousFingerprint && record.renewedAt !== null && this.now() - record.renewedAt <= 864e5)) return this.refuse(input.purpose, "superseded", tags);
7585
+ }
7586
+ const used = {
7587
+ ...record,
7588
+ lastUsedAt: this.now(),
7589
+ lastUsedFrom: input.from ?? record.lastUsedFrom
7590
+ };
7591
+ await this.save(used);
7592
+ return {
7593
+ ok: true,
7594
+ credential: used
7595
+ };
7596
+ }
7597
+ /** A new leaf for the SAME public key, after {@link authenticate} accepted a `renew` proof. */
7598
+ async renew(credential) {
7599
+ const current = this.records.get(credential.id);
7600
+ if (current === void 0) return this.refuse("renew", "unknown-credential", {});
7601
+ if (current.revokedAt !== null) return this.refuse("renew", "revoked", {
7602
+ credentialId: current.id,
7603
+ userId: current.userId
7604
+ });
7605
+ let ca;
7606
+ try {
7607
+ ca = await this.deps.ca.ensure();
7608
+ } catch {
7609
+ return {
7610
+ ok: false,
7611
+ reason: "ca-unavailable"
7612
+ };
7613
+ }
7614
+ const leaf = await issueDeviceLeaf(ca, {
7615
+ credentialId: current.id,
7616
+ userId: current.userId,
7617
+ publicKeySpki: Buffer.from(current.publicKey, "base64url"),
7618
+ now: new Date(this.now())
7619
+ });
7620
+ const updated = {
7621
+ ...current,
7622
+ fingerprint: leaf.fingerprint,
7623
+ serial: leaf.serialNumber,
7624
+ previousFingerprint: current.fingerprint,
7625
+ renewedAt: this.now(),
7626
+ issuedAt: leaf.notBefore.getTime(),
7627
+ expiresAt: leaf.notAfter.getTime()
7628
+ };
7629
+ await this.save(updated);
7630
+ this.deps.logger?.info("Device certificate renewed", { tags: {
7631
+ credentialId: updated.id,
7632
+ userId: updated.userId
7633
+ } });
7634
+ return {
7635
+ ok: true,
7636
+ credentialId: updated.id,
7637
+ userId: updated.userId,
7638
+ certPem: leaf.certPem,
7639
+ chainPem: ca.certPem,
7640
+ notAfter: leaf.notAfter.getTime()
7641
+ };
7642
+ }
7643
+ /** Synchronous: the hub asks on every refresh of a credential-bound session. */
7644
+ check(credentialId) {
7645
+ const record = this.records.get(credentialId);
7646
+ if (record === void 0) return "unknown";
7647
+ if (record.revokedAt !== null) return "revoked";
7648
+ return record.expiresAt <= this.now() ? "expired" : "active";
7649
+ }
7650
+ get(credentialId) {
7651
+ return this.records.get(credentialId);
7652
+ }
7653
+ list(input) {
7654
+ return [...this.records.values()].filter((r) => input.userId === void 0 || r.userId === input.userId).filter((r) => input.includeRevoked || r.revokedAt === null).toSorted((a, b) => b.issuedAt - a.issuedAt);
7655
+ }
7656
+ /**
7657
+ * Delete a REVOKED row (admin clean-up). `live` refuses a credential that is
7658
+ * not revoked — a live credential is revoked first, so a delete can never be
7659
+ * the way a device silently stops working.
7660
+ */
7661
+ async delete(credentialId) {
7662
+ const record = this.records.get(credentialId);
7663
+ if (record === void 0) return "unknown";
7664
+ if (record.revokedAt === null) {
7665
+ this.deps.logger?.warn("Device credential delete refused: still live", { tags: {
7666
+ credentialId,
7667
+ userId: record.userId
7668
+ } });
7669
+ return "live";
7670
+ }
7671
+ this.records.delete(credentialId);
7672
+ await this.store.delete.mutate({
7673
+ collection: DEVICE_CREDENTIALS_COLLECTION,
7674
+ key: credentialId
7675
+ });
7676
+ this.deps.logger?.info("Device credential row deleted", { tags: {
7677
+ credentialId,
7678
+ userId: record.userId
7679
+ } });
7680
+ return "deleted";
7681
+ }
7682
+ /** Revoke; `false` when unknown or already revoked. `revokedBy`: see `formatDeviceRevoker`. */
7683
+ async revoke(credentialId, revokedBy) {
7684
+ const record = this.records.get(credentialId);
7685
+ if (record === void 0 || record.revokedAt !== null) return false;
7686
+ await this.save({
7687
+ ...record,
7688
+ revokedAt: this.now(),
7689
+ revokedBy
7690
+ });
7691
+ this.deps.logger?.warn("Device credential revoked", {
7692
+ tags: {
7693
+ credentialId,
7694
+ userId: record.userId
7695
+ },
7696
+ meta: { revokedBy }
7697
+ });
7698
+ return true;
7699
+ }
7700
+ async rename(credentialId, deviceName) {
7701
+ const record = this.records.get(credentialId);
7702
+ if (record === void 0) return false;
7703
+ await this.save({
7704
+ ...record,
7705
+ deviceName
7706
+ });
7707
+ return true;
7708
+ }
7709
+ /** Revoke every credential of a user (account deleted). Returns their ids. */
7710
+ async revokeAllForUser(userId, revokedBy) {
7711
+ const ids = [...this.records.values()].filter((r) => r.userId === userId && r.revokedAt === null).map((r) => r.id);
7712
+ for (const id of ids) await this.revoke(id, revokedBy);
7713
+ return ids;
7714
+ }
7715
+ toSummary(record, username) {
7716
+ return {
7717
+ id: record.id,
7718
+ userId: record.userId,
7719
+ username,
7720
+ deviceName: record.deviceName,
7721
+ platform: record.platform,
7722
+ enrolledVia: record.enrolledVia,
7723
+ fingerprint: record.fingerprint,
7724
+ issuedAt: record.issuedAt,
7725
+ expiresAt: record.expiresAt,
7726
+ lastUsedAt: record.lastUsedAt,
7727
+ lastUsedFrom: record.lastUsedFrom,
7728
+ revokedAt: record.revokedAt,
7729
+ revokedBy: record.revokedBy,
7730
+ status: record.revokedAt !== null ? "revoked" : record.expiresAt <= this.now() ? "expired" : "active"
7731
+ };
7732
+ }
7733
+ /** Delete rows revoked or expired more than 90 days ago. */
7734
+ async reap() {
7735
+ const cutoff = this.now() - REAP_RETENTION_MS;
7736
+ const dead = [...this.records.values()].filter((r) => (r.revokedAt ?? r.expiresAt) < cutoff);
7737
+ for (const r of dead) {
7738
+ this.records.delete(r.id);
7739
+ await this.store.delete.mutate({
7740
+ collection: DEVICE_CREDENTIALS_COLLECTION,
7741
+ key: r.id
7742
+ });
7743
+ }
7744
+ return dead.length;
7745
+ }
7746
+ async save(record) {
7747
+ this.records.set(record.id, record);
7748
+ try {
7749
+ await this.store.update.mutate({
7750
+ collection: DEVICE_CREDENTIALS_COLLECTION,
7751
+ id: record.id,
7752
+ data: { ...record }
7753
+ });
7754
+ } catch (error) {
7755
+ this.deps.logger?.error("Device credential write failed — the change holds until restart", {
7756
+ tags: {
7757
+ credentialId: record.id,
7758
+ userId: record.userId
7759
+ },
7760
+ meta: { error: error instanceof Error ? error.message : String(error) }
7761
+ });
7762
+ }
7763
+ }
7764
+ refuse(leg, reason, context) {
7765
+ this.deps.logger?.warn(`Device credential ${leg} refused: ${reason}`, {
7766
+ tags: {
7767
+ ...context.credentialId !== void 0 ? { credentialId: context.credentialId } : {},
7768
+ ...context.userId !== void 0 ? { userId: context.userId } : {}
7769
+ },
7770
+ meta: {
7771
+ leg,
7772
+ reason,
7773
+ ...context.detail !== void 0 ? { detail: context.detail } : {}
7774
+ }
7775
+ });
7776
+ return {
7777
+ ok: false,
7778
+ reason
7779
+ };
7780
+ }
7781
+ prune() {
7782
+ const at = this.now();
7783
+ for (const [k, v] of this.pendingEnrollments) if (at > v.expiresAt) this.pendingEnrollments.delete(k);
7784
+ for (const [k, exp] of this.usedCodes) if (at > exp + 6e5) this.usedCodes.delete(k);
7785
+ for (const [k, v] of this.nonces) if (at > v.expiresAt) this.nonces.delete(k);
7786
+ }
7787
+ };
7788
+ //#endregion
7083
7789
  //#region src/auth/auth-audit-log.ts
7084
7790
  /**
7085
7791
  * The authentication audit trail: what an operator must be able to answer
@@ -7134,7 +7840,11 @@ var AUTH_AUDIT_ACTIONS = [
7134
7840
  "admin-token-revoked",
7135
7841
  "credentials-revoked",
7136
7842
  "agent-addon-update",
7137
- "agent-addon-update-refused"
7843
+ "agent-addon-update-refused",
7844
+ "device-credential-enrolled",
7845
+ "device-credential-revoked",
7846
+ "device-credential-login",
7847
+ "device-credential-login-refused"
7138
7848
  ];
7139
7849
  var RETENTION_MS = 90 * 864e5;
7140
7850
  var MAX_LIST = 500;
@@ -7260,7 +7970,7 @@ var USERS_COLLECTION$1 = "users";
7260
7970
  var FIRST_BOOT_ADMIN_USERNAME = "admin";
7261
7971
  var FIRST_BOOT_ADMIN_PASSWORD = "changeme";
7262
7972
  function parseUser(data) {
7263
- return parseRecord$1("user", UserRecordSchema, data);
7973
+ return parseRecord$2("user", UserRecordSchema, data);
7264
7974
  }
7265
7975
  var UserManager = class {
7266
7976
  storageAccess;
@@ -7854,6 +8564,14 @@ async function declareAuthSchema(store) {
7854
8564
  columns: ["createdBy"]
7855
8565
  }]
7856
8566
  });
8567
+ await store.declareCollection.mutate({
8568
+ collection: DEVICE_CREDENTIALS_COLLECTION,
8569
+ columns: DEVICE_CREDENTIALS_COLUMNS,
8570
+ indexes: [{
8571
+ name: "idx_device_credentials_user_id",
8572
+ columns: ["userId"]
8573
+ }]
8574
+ });
7857
8575
  await store.declareCollection.mutate({
7858
8576
  collection: AUTH_AUDIT_COLLECTION,
7859
8577
  columns: AUTH_AUDIT_COLUMNS,
@@ -10751,6 +11469,8 @@ var LocalAuthAddon = class extends BaseAddon {
10751
11469
  loginSessions = null;
10752
11470
  adminTokens = null;
10753
11471
  auditLog = null;
11472
+ deviceCredentials = null;
11473
+ deviceCa = null;
10754
11474
  constructor() {
10755
11475
  super({});
10756
11476
  }
@@ -10780,6 +11500,25 @@ var LocalAuthAddon = class extends BaseAddon {
10780
11500
  await this.loginSessions.load();
10781
11501
  this.adminTokens = new AdminTokenManager(store);
10782
11502
  this.auditLog = new AuthAuditLog(store, this.ctx.logger);
11503
+ const deviceUsers = this.userManager;
11504
+ this.deviceCa = new DeviceCaStore({
11505
+ secrets: forOwner === null ? null : forOwner("system"),
11506
+ readSection: async (section) => await this.ctx.settings?.getSection(section) ?? {},
11507
+ writeSection: async (section, patch) => {
11508
+ await this.ctx.settings?.setSection(section, patch);
11509
+ },
11510
+ logger: this.ctx.logger
11511
+ });
11512
+ this.deviceCredentials = new DeviceCredentialManager(store, {
11513
+ ca: this.deviceCa,
11514
+ findUser: async (userId) => {
11515
+ const user = await deviceUsers.findById(userId);
11516
+ return user === null ? null : { username: user.username };
11517
+ },
11518
+ selfEnrollmentAllowed: async () => (await this.readDeviceCertificateSettings()).selfEnrollment === "allowed",
11519
+ logger: this.ctx.logger
11520
+ });
11521
+ await this.deviceCredentials.load();
10783
11522
  try {
10784
11523
  await reconcileAdminAccountAtBoot({
10785
11524
  users: this.userManager,
@@ -10879,6 +11618,7 @@ var LocalAuthAddon = class extends BaseAddon {
10879
11618
  if (!this.userManager) throw new Error("User management not available");
10880
11619
  await this.userManager.resetPassword(input.id, input.newPassword);
10881
11620
  await this.loginSessions?.revokeAllForUser(input.id, { reason: "password-reset" });
11621
+ await this.revokeDeviceCredentialsForReset(input.id);
10882
11622
  return { success: true };
10883
11623
  },
10884
11624
  setUserScopes: async (input) => {
@@ -11003,13 +11743,154 @@ var LocalAuthAddon = class extends BaseAddon {
11003
11743
  };
11004
11744
  this.ctx.logger.info("registered auth-provider + user-management capabilities");
11005
11745
  attachUserManagementInternal(userMgmt, this.buildInternal());
11006
- return [{
11007
- capability: authProviderCapability,
11008
- provider: authProvider
11009
- }, {
11010
- capability: userManagementCapability,
11011
- provider: userMgmt
11012
- }];
11746
+ return [
11747
+ {
11748
+ capability: authProviderCapability,
11749
+ provider: authProvider
11750
+ },
11751
+ {
11752
+ capability: userManagementCapability,
11753
+ provider: userMgmt
11754
+ },
11755
+ {
11756
+ capability: deviceCredentialsCapability,
11757
+ provider: this.buildDeviceCredentialsProvider()
11758
+ }
11759
+ ];
11760
+ }
11761
+ async readDeviceCertificateSettings() {
11762
+ return { selfEnrollment: (await this.ctx.settings?.getSection("deviceCertificates") ?? {})["selfEnrollment"] === "disabled" ? "disabled" : "allowed" };
11763
+ }
11764
+ async revokeDeviceCredentialsForReset(userId, by = "password-reset") {
11765
+ const ids = await this.deviceCredentials?.revokeAllForUser(userId, by) ?? [];
11766
+ for (const id of ids) {
11767
+ const ended = await this.loginSessions?.revokeByCredential(id) ?? 0;
11768
+ await this.auditLog?.record({
11769
+ action: "device-credential-revoked",
11770
+ userId,
11771
+ detail: {
11772
+ credentialId: id,
11773
+ by,
11774
+ sessionsEnded: ended
11775
+ }
11776
+ });
11777
+ }
11778
+ }
11779
+ /** The admin cap (`device-credentials`): list, enroll-by-QR, revoke, rename, settings. */
11780
+ buildDeviceCredentialsProvider() {
11781
+ const manager = () => {
11782
+ if (this.deviceCredentials === null) throw new Error("Device credentials not available");
11783
+ return this.deviceCredentials;
11784
+ };
11785
+ const hubOrigins = async () => {
11786
+ try {
11787
+ const book = await this.ctx.api?.localNetwork?.getAddresses?.query({});
11788
+ if (!book) return [];
11789
+ const ordered = [book.roles.publicEndpoint.resolved, ...book.roles.appConnections.resolved];
11790
+ return [...new Set(ordered.filter((o) => typeof o === "string"))].filter((o) => !/^https?:\/\/(127\.|localhost|\[::1\])/i.test(o));
11791
+ } catch (err) {
11792
+ this.ctx.logger.warn("Enrollment link: the hub address could not be resolved", { meta: { error: err instanceof Error ? err.message : String(err) } });
11793
+ return [];
11794
+ }
11795
+ };
11796
+ return {
11797
+ listDeviceCredentials: async (input) => {
11798
+ const users = new Map((await this.userManager?.listAll() ?? []).map((u) => [u.id, u.username]));
11799
+ return manager().list({
11800
+ ...input.userId !== void 0 ? { userId: input.userId } : {},
11801
+ includeRevoked: input.includeRevoked
11802
+ }).map((r) => manager().toSummary(r, users.get(r.userId) ?? null));
11803
+ },
11804
+ startEnrollment: async (input) => {
11805
+ if (await this.userManager?.findById(input.userId) == null) throw new Error(`User not found: ${input.userId}`);
11806
+ const started = manager().startEnrollment({
11807
+ userId: input.userId,
11808
+ deviceName: input.deviceName,
11809
+ createdBy: input.caller.userId
11810
+ });
11811
+ const origins = await hubOrigins();
11812
+ const hub = origins[0];
11813
+ await this.auditLog?.record({
11814
+ action: "device-credential-enrolled",
11815
+ userId: input.userId,
11816
+ detail: {
11817
+ stage: "code-issued",
11818
+ by: input.caller.userId
11819
+ }
11820
+ });
11821
+ return {
11822
+ code: started.code,
11823
+ expiresAt: started.expiresAt,
11824
+ enrollUrl: buildDeviceEnrollmentLink({
11825
+ ...hub !== void 0 ? { hub } : {},
11826
+ code: started.code
11827
+ }),
11828
+ hubOrigin: hub ?? null,
11829
+ hubOrigins: [...origins]
11830
+ };
11831
+ },
11832
+ revokeDeviceCredential: async (input) => {
11833
+ const record = manager().get(input.id);
11834
+ if (record === void 0) throw new Error(`Device credential not found: ${input.id}`);
11835
+ const revoked = await manager().revoke(input.id, formatDeviceRevoker({
11836
+ kind: "admin",
11837
+ userId: input.caller.userId
11838
+ }));
11839
+ const sessionsEnded = await this.loginSessions?.revokeByCredential(input.id) ?? 0;
11840
+ if (revoked) await this.auditLog?.record({
11841
+ action: "device-credential-revoked",
11842
+ userId: record.userId,
11843
+ detail: {
11844
+ credentialId: input.id,
11845
+ by: input.caller.userId,
11846
+ sessionsEnded
11847
+ }
11848
+ });
11849
+ return {
11850
+ revoked,
11851
+ sessionsEnded,
11852
+ status: "revoked"
11853
+ };
11854
+ },
11855
+ deleteDeviceCredential: async (input) => {
11856
+ const record = manager().get(input.id);
11857
+ const result = await manager().delete(input.id);
11858
+ if (result === "deleted" && record !== void 0) await this.auditLog?.record({
11859
+ action: "device-credential-revoked",
11860
+ userId: record.userId,
11861
+ detail: {
11862
+ credentialId: input.id,
11863
+ stage: "row-deleted",
11864
+ by: input.caller.userId
11865
+ }
11866
+ });
11867
+ return { result };
11868
+ },
11869
+ renameDeviceCredential: async (input) => ({ renamed: await manager().rename(input.id, input.deviceName) }),
11870
+ getDeviceCaStatus: async () => {
11871
+ if (this.deviceCa === null) return {
11872
+ state: "unavailable",
11873
+ reason: "not initialised"
11874
+ };
11875
+ const found = await this.deviceCa.lookup();
11876
+ if (found.state !== "ready") return found.state === "absent" ? { state: "absent" } : {
11877
+ state: "unavailable",
11878
+ reason: found.reason
11879
+ };
11880
+ const x509 = new X509Certificate(found.ca.certPem);
11881
+ return {
11882
+ state: "ready",
11883
+ subject: x509.subject,
11884
+ fingerprintSha256: x509.fingerprint256,
11885
+ validTo: x509.validTo
11886
+ };
11887
+ },
11888
+ getDeviceCertificateSettings: async () => this.readDeviceCertificateSettings(),
11889
+ setDeviceCertificateSettings: async (input) => {
11890
+ await this.ctx.settings?.setSection("deviceCertificates", { selfEnrollment: input.selfEnrollment });
11891
+ return this.readDeviceCertificateSettings();
11892
+ }
11893
+ };
11013
11894
  }
11014
11895
  /**
11015
11896
  * The hub-internal surface (`user-management-internal.ts`): operations no
@@ -11020,6 +11901,10 @@ var LocalAuthAddon = class extends BaseAddon {
11020
11901
  if (this.loginSessions === null) throw new Error("Login sessions not available");
11021
11902
  return this.loginSessions;
11022
11903
  };
11904
+ const devices = () => {
11905
+ if (this.deviceCredentials === null) throw new Error("Device credentials not available");
11906
+ return this.deviceCredentials;
11907
+ };
11023
11908
  return {
11024
11909
  sessions: {
11025
11910
  create: (input) => sessions().create(input),
@@ -11030,7 +11915,20 @@ var LocalAuthAddon = class extends BaseAddon {
11030
11915
  revoke: (id, reason) => sessions().revoke(id, reason),
11031
11916
  revokeAllForUser: (userId, options) => sessions().revokeAllForUser(userId, options),
11032
11917
  listForUser: (userId) => sessions().listForUser(userId),
11033
- listActive: () => sessions().listActive()
11918
+ listActive: () => sessions().listActive(),
11919
+ revokeByCredential: (credentialId, reason) => sessions().revokeByCredential(credentialId, reason),
11920
+ peekByRefreshToken: (token) => sessions().peekByRefreshToken(token)
11921
+ },
11922
+ deviceCredentials: {
11923
+ completeEnrollment: (input) => devices().completeEnrollment(input),
11924
+ enrollSelf: (input) => devices().enrollSelf(input),
11925
+ issueNonce: (origin) => devices().issueNonce(origin),
11926
+ authenticate: (input) => devices().authenticate(input),
11927
+ renew: (credential) => devices().renew(credential),
11928
+ check: (id) => devices().check(id),
11929
+ get: (id) => devices().get(id),
11930
+ revoke: (id, by) => devices().revoke(id, by),
11931
+ list: (input) => devices().list(input)
11034
11932
  },
11035
11933
  adminTokens: {
11036
11934
  create: async (input) => {
@@ -11068,6 +11966,7 @@ var LocalAuthAddon = class extends BaseAddon {
11068
11966
  ...input.keepSessionId !== void 0 ? { exceptSessionId: input.keepSessionId } : {},
11069
11967
  reason: "password-changed"
11070
11968
  });
11969
+ await this.revokeDeviceCredentialsForReset(input.userId, "password-changed");
11071
11970
  },
11072
11971
  linkExternalIdentity: async (input) => {
11073
11972
  if (!this.externalIdentities || !this.userManager) throw new Error("External sign-in linking not available");
@@ -11094,6 +11993,9 @@ var LocalAuthAddon = class extends BaseAddon {
11094
11993
  for (const session of await this.oauthSessionManager?.list() ?? []) if (session.userId === userId && session.revokedAt === null) await this.oauthSessionManager?.markRevoked(session.id);
11095
11994
  }],
11096
11995
  ["admin tokens", async () => this.adminTokens?.revokeAllCreatedBy(userId)],
11996
+ ["device credentials", async () => {
11997
+ for (const id of await this.deviceCredentials?.revokeAllForUser(userId, "user-deleted") ?? []) await this.loginSessions?.revokeByCredential(id);
11998
+ }],
11097
11999
  ["totp", async () => this.totpManager?.disable(userId)],
11098
12000
  ["sign-in links", async () => this.externalIdentities?.removeAllForUser(userId)]
11099
12001
  ];
@@ -11123,6 +12025,7 @@ var LocalAuthAddon = class extends BaseAddon {
11123
12025
  const logins = await this.loginSessions?.reap() ?? 0;
11124
12026
  await this.adminTokens?.reap();
11125
12027
  await this.auditLog?.reap();
12028
+ await this.deviceCredentials?.reap();
11126
12029
  if (tokens > 0 || sessions > 0 || logins > 0) this.ctx.logger.info("auth retention swept", { meta: {
11127
12030
  expiredTokens: tokens,
11128
12031
  revokedSessions: sessions,
@@ -11148,7 +12051,9 @@ var LocalAuthAddon = class extends BaseAddon {
11148
12051
  this.loginSessions = null;
11149
12052
  this.adminTokens = null;
11150
12053
  this.auditLog = null;
12054
+ this.deviceCredentials = null;
12055
+ this.deviceCa = null;
11151
12056
  }
11152
12057
  };
11153
12058
  //#endregion
11154
- export { LocalAuthAddon, LocalAuthAddon as default, isSettingsReadUnavailable as _, attachUserManagementInternal as a, ADMIN_TOKEN_MAX_LIFETIME_MS as c, LoginSessionManager as d, REFRESH_TOKEN_PREFIX as f, SettingsReadUnavailableError as g, parseTokenExpiry as h, USER_MANAGEMENT_INTERNAL as i, ADMIN_TOKEN_PREFIX as l, AuthManager as m, FIRST_BOOT_ADMIN_USERNAME as n, getUserManagementInternal as o, ScopedTokenManager as p, UserManager as r, AuthAuditLog as s, FIRST_BOOT_ADMIN_PASSWORD as t, AdminTokenManager as u, ApiKeyManager as v };
12059
+ export { LocalAuthAddon, LocalAuthAddon as default, AuthManager as _, attachUserManagementInternal as a, isSettingsReadUnavailable as b, DeviceCredentialManager as c, ADMIN_TOKEN_MAX_LIFETIME_MS as d, ADMIN_TOKEN_PREFIX as f, ScopedTokenManager as g, REFRESH_TOKEN_PREFIX as h, USER_MANAGEMENT_INTERNAL as i, PREVIOUS_LEAF_GRACE_MS as l, LoginSessionManager as m, FIRST_BOOT_ADMIN_USERNAME as n, getUserManagementInternal as o, AdminTokenManager as p, UserManager as r, AuthAuditLog as s, FIRST_BOOT_ADMIN_PASSWORD as t, DeviceCaStore as u, parseTokenExpiry as v, ApiKeyManager as x, SettingsReadUnavailableError as y };