@skrr-ai/auth-core 0.1.2

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 (147) hide show
  1. package/README.md +21 -0
  2. package/dist/cjs/aead.d.ts +24 -0
  3. package/dist/cjs/aead.js +65 -0
  4. package/dist/cjs/authExpiredDetector.d.ts +65 -0
  5. package/dist/cjs/authExpiredDetector.js +89 -0
  6. package/dist/cjs/authHelper.d.ts +51 -0
  7. package/dist/cjs/authHelper.js +321 -0
  8. package/dist/cjs/credentialEnvelope.d.ts +85 -0
  9. package/dist/cjs/credentialEnvelope.js +230 -0
  10. package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
  11. package/dist/cjs/credentialEnvelopeBridge.js +637 -0
  12. package/dist/cjs/credentialResolver.d.ts +278 -0
  13. package/dist/cjs/credentialResolver.js +423 -0
  14. package/dist/cjs/daemonPair.d.ts +172 -0
  15. package/dist/cjs/daemonPair.js +495 -0
  16. package/dist/cjs/daemonScopes.d.ts +74 -0
  17. package/dist/cjs/daemonScopes.js +84 -0
  18. package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
  19. package/dist/cjs/deviceIdentityBridge.js +591 -0
  20. package/dist/cjs/deviceKey.d.ts +167 -0
  21. package/dist/cjs/deviceKey.js +332 -0
  22. package/dist/cjs/fdToken.d.ts +17 -0
  23. package/dist/cjs/fdToken.js +238 -0
  24. package/dist/cjs/harnessTrust.d.ts +72 -0
  25. package/dist/cjs/harnessTrust.js +103 -0
  26. package/dist/cjs/headers-helper.d.ts +25 -0
  27. package/dist/cjs/headers-helper.js +323 -0
  28. package/dist/cjs/index.d.ts +37 -0
  29. package/dist/cjs/index.js +305 -0
  30. package/dist/cjs/jwtUtils.d.ts +35 -0
  31. package/dist/cjs/jwtUtils.js +61 -0
  32. package/dist/cjs/kek/index.d.ts +89 -0
  33. package/dist/cjs/kek/index.js +208 -0
  34. package/dist/cjs/kek/linux.d.ts +92 -0
  35. package/dist/cjs/kek/linux.js +596 -0
  36. package/dist/cjs/kek/macos.d.ts +112 -0
  37. package/dist/cjs/kek/macos.js +599 -0
  38. package/dist/cjs/kek/types.d.ts +111 -0
  39. package/dist/cjs/kek/types.js +43 -0
  40. package/dist/cjs/kek/windows.d.ts +71 -0
  41. package/dist/cjs/kek/windows.js +350 -0
  42. package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
  43. package/dist/cjs/kek/zeroize-registry.js +63 -0
  44. package/dist/cjs/legacyStatePreflight.d.ts +67 -0
  45. package/dist/cjs/legacyStatePreflight.js +78 -0
  46. package/dist/cjs/localIdentity.d.ts +54 -0
  47. package/dist/cjs/localIdentity.js +57 -0
  48. package/dist/cjs/loginLocalhost.d.ts +115 -0
  49. package/dist/cjs/loginLocalhost.js +368 -0
  50. package/dist/cjs/loginWithLocalhost.d.ts +131 -0
  51. package/dist/cjs/loginWithLocalhost.js +359 -0
  52. package/dist/cjs/machineId.d.ts +35 -0
  53. package/dist/cjs/machineId.js +239 -0
  54. package/dist/cjs/messages.d.ts +9 -0
  55. package/dist/cjs/messages.js +44 -0
  56. package/dist/cjs/package.json +3 -0
  57. package/dist/cjs/pkce.d.ts +62 -0
  58. package/dist/cjs/pkce.js +158 -0
  59. package/dist/cjs/recoveryCode.d.ts +61 -0
  60. package/dist/cjs/recoveryCode.js +213 -0
  61. package/dist/cjs/refresh.d.ts +215 -0
  62. package/dist/cjs/refresh.js +877 -0
  63. package/dist/cjs/refreshClassification.d.ts +53 -0
  64. package/dist/cjs/refreshClassification.js +105 -0
  65. package/dist/cjs/refreshScheduler.d.ts +53 -0
  66. package/dist/cjs/refreshScheduler.js +332 -0
  67. package/dist/cjs/runtime.d.ts +149 -0
  68. package/dist/cjs/runtime.js +136 -0
  69. package/dist/cjs/spawnEnv.d.ts +76 -0
  70. package/dist/cjs/spawnEnv.js +153 -0
  71. package/dist/cjs/ttlParser.d.ts +54 -0
  72. package/dist/cjs/ttlParser.js +87 -0
  73. package/dist/cjs/types.d.ts +91 -0
  74. package/dist/cjs/types.js +26 -0
  75. package/dist/esm/aead.d.ts +24 -0
  76. package/dist/esm/aead.js +57 -0
  77. package/dist/esm/authExpiredDetector.d.ts +65 -0
  78. package/dist/esm/authExpiredDetector.js +85 -0
  79. package/dist/esm/authHelper.d.ts +51 -0
  80. package/dist/esm/authHelper.js +311 -0
  81. package/dist/esm/credentialEnvelope.d.ts +85 -0
  82. package/dist/esm/credentialEnvelope.js +213 -0
  83. package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
  84. package/dist/esm/credentialEnvelopeBridge.js +620 -0
  85. package/dist/esm/credentialResolver.d.ts +278 -0
  86. package/dist/esm/credentialResolver.js +414 -0
  87. package/dist/esm/daemonPair.d.ts +172 -0
  88. package/dist/esm/daemonPair.js +476 -0
  89. package/dist/esm/daemonScopes.d.ts +74 -0
  90. package/dist/esm/daemonScopes.js +80 -0
  91. package/dist/esm/deviceIdentityBridge.d.ts +113 -0
  92. package/dist/esm/deviceIdentityBridge.js +572 -0
  93. package/dist/esm/deviceKey.d.ts +167 -0
  94. package/dist/esm/deviceKey.js +320 -0
  95. package/dist/esm/fdToken.d.ts +17 -0
  96. package/dist/esm/fdToken.js +231 -0
  97. package/dist/esm/harnessTrust.d.ts +72 -0
  98. package/dist/esm/harnessTrust.js +97 -0
  99. package/dist/esm/headers-helper.d.ts +25 -0
  100. package/dist/esm/headers-helper.js +316 -0
  101. package/dist/esm/index.d.ts +37 -0
  102. package/dist/esm/index.js +144 -0
  103. package/dist/esm/jwtUtils.d.ts +35 -0
  104. package/dist/esm/jwtUtils.js +57 -0
  105. package/dist/esm/kek/index.d.ts +89 -0
  106. package/dist/esm/kek/index.js +162 -0
  107. package/dist/esm/kek/linux.d.ts +92 -0
  108. package/dist/esm/kek/linux.js +585 -0
  109. package/dist/esm/kek/macos.d.ts +112 -0
  110. package/dist/esm/kek/macos.js +586 -0
  111. package/dist/esm/kek/types.d.ts +111 -0
  112. package/dist/esm/kek/types.js +39 -0
  113. package/dist/esm/kek/windows.d.ts +71 -0
  114. package/dist/esm/kek/windows.js +344 -0
  115. package/dist/esm/kek/zeroize-registry.d.ts +44 -0
  116. package/dist/esm/kek/zeroize-registry.js +58 -0
  117. package/dist/esm/legacyStatePreflight.d.ts +67 -0
  118. package/dist/esm/legacyStatePreflight.js +71 -0
  119. package/dist/esm/localIdentity.d.ts +54 -0
  120. package/dist/esm/localIdentity.js +54 -0
  121. package/dist/esm/loginLocalhost.d.ts +115 -0
  122. package/dist/esm/loginLocalhost.js +355 -0
  123. package/dist/esm/loginWithLocalhost.d.ts +131 -0
  124. package/dist/esm/loginWithLocalhost.js +353 -0
  125. package/dist/esm/machineId.d.ts +35 -0
  126. package/dist/esm/machineId.js +231 -0
  127. package/dist/esm/messages.d.ts +9 -0
  128. package/dist/esm/messages.js +40 -0
  129. package/dist/esm/pkce.d.ts +62 -0
  130. package/dist/esm/pkce.js +148 -0
  131. package/dist/esm/recoveryCode.d.ts +61 -0
  132. package/dist/esm/recoveryCode.js +207 -0
  133. package/dist/esm/refresh.d.ts +215 -0
  134. package/dist/esm/refresh.js +863 -0
  135. package/dist/esm/refreshClassification.d.ts +53 -0
  136. package/dist/esm/refreshClassification.js +106 -0
  137. package/dist/esm/refreshScheduler.d.ts +53 -0
  138. package/dist/esm/refreshScheduler.js +329 -0
  139. package/dist/esm/runtime.d.ts +149 -0
  140. package/dist/esm/runtime.js +121 -0
  141. package/dist/esm/spawnEnv.d.ts +76 -0
  142. package/dist/esm/spawnEnv.js +149 -0
  143. package/dist/esm/ttlParser.d.ts +54 -0
  144. package/dist/esm/ttlParser.js +83 -0
  145. package/dist/esm/types.d.ts +91 -0
  146. package/dist/esm/types.js +21 -0
  147. package/package.json +82 -0
@@ -0,0 +1,585 @@
1
+ /**
2
+ * kek/linux.ts — Linux KEK strategy. Two-tier selector chosen once per
3
+ * process based on what the host environment supports.
4
+ *
5
+ * Tier-2 (libsecret shellout): when `secret-tool` (libsecret) and a
6
+ * reachable D-Bus Secret Service session are both present, the master
7
+ * KEK lives in the user's keyring (gnome-keyring on GNOME,
8
+ * ksecretservice on KDE — both speak the freedesktop.org Secret
9
+ * Service API). The keyring is encrypted at rest under the user's
10
+ * login and is session-gated, so neither cold-disk theft nor a
11
+ * same-UID file reader without the keystore ACL can recover the KEK.
12
+ *
13
+ * Tier-3 (machine-id HKDF): when libsecret is unavailable (headless
14
+ * server, no D-Bus session, no `secret-tool` binary), the master KEK
15
+ * is derived deterministically via HKDF-SHA256 from machine identity
16
+ * (hostname + UID + loginuid + /etc/machine-id). No KEK is ever
17
+ * written to disk on this path.
18
+ *
19
+ * Tier-3 threat model — be precise about what we ship vs. what we don't:
20
+ * - DEFENDS against backup theft to a different machine: an attacker
21
+ * who copies the wrapped credential file off the host cannot
22
+ * re-derive the KEK without also reading machine-id from the
23
+ * original host (different machine-id ⇒ different KEK ⇒ GCM tag
24
+ * mismatch on unwrap).
25
+ * - DOES NOT defend against same-user offline brute force on the SAME
26
+ * machine. Tier-3 inputs (hostname, UID, loginuid, /etc/machine-id)
27
+ * are world-readable to a process running on the host. An attacker
28
+ * who lands code on the box can recompute the KEK deterministically
29
+ * and decrypt the wrapped DEK without ever touching libsecret.
30
+ * - Tier-3 is therefore a FLOOR, not a ceiling: shipping it is
31
+ * strictly better than the legacy plaintext-on-disk path, but it
32
+ * does not match Tier-2's session-gated guarantee. We ship it
33
+ * unconditionally because headless servers without a session keyring
34
+ * would otherwise have no KEK protection at all.
35
+ *
36
+ * Race semantics on Tier-2 first-run enrollment: second-writer-wins (two
37
+ * daemon processes both missing on the same TOCTOU window both call
38
+ * `secret-tool store`, the second overwrites the first). Any DEKs
39
+ * wrapped under the first KEK become unrecoverable. The upstream caller
40
+ * (`credEnvelope.ts:initCredEnvelope`) handles this gracefully — unwrap
41
+ * failure → log + treat as first enrollment. A proper-lockfile layer
42
+ * upstream eliminates the race in practice. Tier-3 has no race because
43
+ * the KEK is recomputed (not stored) on every cold start.
44
+ *
45
+ * Service/account override (Tier-2 only): the `LinuxKek` constructor
46
+ * accepts an optional `{ service, account }` override that scopes the
47
+ * Secret Service item attributes used by `secret-tool lookup`/`store`.
48
+ * This exists for multi-tenant test isolation and for the real-shellout
49
+ * smoke test, which uses a per-run unique service name so it cannot
50
+ * pollute a developer's libsecret store. The override only affects
51
+ * Tier-2; Tier-3 derives the KEK via HKDF from machine identity and
52
+ * has no service/account concept — overrides are silently ignored on
53
+ * the HKDF path. Callers that override the service are responsible for
54
+ * cleaning up the corresponding Secret Service entry when finished.
55
+ */
56
+ import { execFile } from 'node:child_process';
57
+ import { promisify } from 'node:util';
58
+ import crypto from 'node:crypto';
59
+ import os from 'node:os';
60
+ import fs from 'node:fs';
61
+ import { openBuffer, sealBuffer, deserialize as envelopeDeserialize, serialize as envelopeSerialize, KEY_LEN, } from '../credentialEnvelope.js';
62
+ import { KekUnavailableError } from './types.js';
63
+ import { registerZeroizeHook } from './zeroize-registry.js';
64
+ /** Stable telemetry kind strings — one per backend tier. */
65
+ const KIND_LIBSECRET = 'linux-libsecret-shellout';
66
+ const KIND_HKDF = 'linux-machineid-hkdf';
67
+ /** Secret Service item attributes for the master KEK (Tier-2). */
68
+ const SS_SERVICE = 'ai.skrr.daemon.kek';
69
+ const SS_ACCOUNT = 'master-kek-v1';
70
+ /** D-Bus transport error substrings — any of these in stderr means no session. */
71
+ const DBUS_TRANSPORT_ERRORS = [
72
+ 'D-Bus',
73
+ 'org.freedesktop.secrets',
74
+ 'No such interface',
75
+ 'Cannot autolaunch',
76
+ ];
77
+ /** HKDF-SHA256 parameters for Tier-3. Stable across versions; bumping the
78
+ * `v1` suffix would silently invalidate every wrapped DEK on disk. */
79
+ const HKDF_SALT = Buffer.from('oversky-cred-kek-salt-v1');
80
+ const HKDF_INFO = Buffer.from('oversky-cred-kek-v1');
81
+ /** Filesystem paths for Tier-3 IKM components. */
82
+ const PATH_LOGINUID = '/proc/self/loginuid';
83
+ const PATH_MACHINE_ID = '/etc/machine-id';
84
+ const execFileAsync = promisify(execFile);
85
+ let _execFileForTest = null;
86
+ /**
87
+ * @internal Test seam — inject a mock execFile for hermetic tests so the
88
+ * suite never touches the real Secret Service. Reset to `null` to restore
89
+ * the real `child_process.execFile`.
90
+ */
91
+ export function __setExecFileForTest(fn) {
92
+ _execFileForTest = fn;
93
+ // Probe + tier selection caches are keyed to the process; reset so the
94
+ // next available() call re-evaluates with the new mock.
95
+ _secretToolProbeCache = undefined;
96
+ _tierSelection = null;
97
+ }
98
+ /** Default execFile wrapper used when no test override is installed. */
99
+ const defaultExec = async (file, args, options) => {
100
+ const result = await execFileAsync(file, args, {
101
+ encoding: 'utf-8',
102
+ ...(options ?? {}),
103
+ });
104
+ // Defensive coercion — when callers override the encoding, stdout may
105
+ // surface as Buffer/Uint8Array; the typeof guard keeps both shapes safe.
106
+ return {
107
+ stdout: typeof result.stdout === 'string'
108
+ ? result.stdout
109
+ : Buffer.from(result.stdout).toString('utf-8'),
110
+ stderr: typeof result.stderr === 'string'
111
+ ? result.stderr
112
+ : Buffer.from(result.stderr).toString('utf-8'),
113
+ };
114
+ };
115
+ function exec(file, args, options) {
116
+ const impl = _execFileForTest ?? defaultExec;
117
+ return impl(file, args, { encoding: 'utf-8', ...options });
118
+ }
119
+ let _readFileSyncForTest = null;
120
+ /**
121
+ * @internal Test seam — inject a mock readFileSync so Tier-3 derivation
122
+ * tests can drive deterministic IKM without writing to /proc or /etc.
123
+ * Reset to `null` to restore the real `fs.readFileSync`. Pass `null` to
124
+ * also clear the tier selection + KEK cache so the next call re-probes.
125
+ */
126
+ export function __setFsForTest(fn) {
127
+ _readFileSyncForTest = fn;
128
+ _tierSelection = null;
129
+ zeroizeLinuxKekCache();
130
+ }
131
+ function readFileTrimmed(path) {
132
+ const reader = _readFileSyncForTest ?? fs.readFileSync.bind(fs);
133
+ try {
134
+ const raw = reader(path, 'utf-8');
135
+ return raw.trim();
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // Probe — cached for the process lifetime.
143
+ // ---------------------------------------------------------------------------
144
+ /** `undefined` = not yet probed; `boolean` = cached probe outcome. */
145
+ let _secretToolProbeCache;
146
+ /**
147
+ * Probe secret-tool availability. Two checks:
148
+ * 1. `secret-tool --version` — ENOENT or exit 127 means binary missing.
149
+ * 2. `secret-tool search service ai.skrr.daemon.kek.probe` — exit 1
150
+ * with no output is fine (nothing matched), but stderr containing a
151
+ * D-Bus transport error means the session bus is not running.
152
+ */
153
+ async function probeSecretTool() {
154
+ if (process.platform !== 'linux')
155
+ return false;
156
+ if (_secretToolProbeCache !== undefined)
157
+ return _secretToolProbeCache;
158
+ // Step 1: binary presence.
159
+ try {
160
+ await exec('secret-tool', ['--version']);
161
+ }
162
+ catch (err) {
163
+ const code = err.code;
164
+ const status = err.status;
165
+ if (code === 'ENOENT' || status === 127) {
166
+ _secretToolProbeCache = false;
167
+ return false;
168
+ }
169
+ // Binary exists but --version exited non-zero; treat as present and
170
+ // continue to the D-Bus probe.
171
+ }
172
+ // Step 2: D-Bus session probe.
173
+ try {
174
+ await exec('secret-tool', ['search', 'service', 'ai.skrr.daemon.kek.probe']);
175
+ // Exit 0 (found something) — fine.
176
+ _secretToolProbeCache = true;
177
+ }
178
+ catch (err) {
179
+ const stderr = err.stderr ?? '';
180
+ const isTransportError = DBUS_TRANSPORT_ERRORS.some((s) => stderr.includes(s));
181
+ if (isTransportError) {
182
+ _secretToolProbeCache = false;
183
+ return false;
184
+ }
185
+ // Exit 1 with empty stderr (no item found) — that's the expected case,
186
+ // it just means no item matched the probe key. D-Bus is working.
187
+ _secretToolProbeCache = true;
188
+ }
189
+ return _secretToolProbeCache;
190
+ }
191
+ // ---------------------------------------------------------------------------
192
+ // Tier-3: HKDF-SHA256 from machine identity.
193
+ // ---------------------------------------------------------------------------
194
+ /**
195
+ * Derive the master KEK from machine identity via HKDF-SHA256. IKM is the
196
+ * NUL-separated concatenation of:
197
+ * - os.hostname()
198
+ * - process UID (or "0" when not exposed)
199
+ * - /proc/self/loginuid (empty string when unreadable)
200
+ * - /etc/machine-id (REQUIRED — throws KekUnavailableError when absent)
201
+ *
202
+ * machine-id is the only required input because it's the cross-boot
203
+ * binding anchor; without it, the derivation has nothing host-specific
204
+ * to bind to and Tier-3 collapses into "shared secret across all hosts."
205
+ */
206
+ function deriveTier3Kek() {
207
+ const hostname = os.hostname();
208
+ const uid = String(process.getuid?.() ?? 0);
209
+ const loginuid = readFileTrimmed(PATH_LOGINUID) ?? '';
210
+ const machineId = readFileTrimmed(PATH_MACHINE_ID);
211
+ if (machineId === null || machineId.length === 0) {
212
+ throw new KekUnavailableError(KIND_HKDF, 'Tier-3 unavailable: /etc/machine-id is missing or empty');
213
+ }
214
+ const NUL = Buffer.from([0]);
215
+ const ikm = Buffer.concat([
216
+ Buffer.from(hostname, 'utf-8'),
217
+ NUL,
218
+ Buffer.from(uid, 'utf-8'),
219
+ NUL,
220
+ Buffer.from(loginuid, 'utf-8'),
221
+ NUL,
222
+ Buffer.from(machineId, 'utf-8'),
223
+ ]);
224
+ // crypto.hkdfSync returns an ArrayBuffer — coerce to Buffer for the
225
+ // rest of the pipeline (which expects Node Buffers, not ArrayBuffers).
226
+ const derived = crypto.hkdfSync('sha256', ikm, HKDF_SALT, HKDF_INFO, KEY_LEN);
227
+ return Buffer.from(derived);
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // Secret Service read/write — master KEK (Tier-2).
231
+ // ---------------------------------------------------------------------------
232
+ /**
233
+ * Read the master KEK from the Secret Service. Returns null when no item
234
+ * exists (first run). Throws `KekUnavailableError` on all other failures.
235
+ *
236
+ * `service` and `account` are passed in (rather than read from the
237
+ * module-level constants) so a `LinuxKek` instance constructed with an
238
+ * override scopes its lookups to a unique Secret Service entry.
239
+ */
240
+ async function readKekFromSecretService(service, account) {
241
+ try {
242
+ const { stdout } = await exec('secret-tool', [
243
+ 'lookup',
244
+ 'service',
245
+ service,
246
+ 'account',
247
+ account,
248
+ ]);
249
+ const trimmed = stdout.trim();
250
+ if (trimmed.length === 0)
251
+ return null; // item not present
252
+ let raw;
253
+ try {
254
+ raw = Buffer.from(trimmed, 'base64');
255
+ }
256
+ catch {
257
+ throw new KekUnavailableError(KIND_LIBSECRET, 'Secret Service KEK is not valid base64');
258
+ }
259
+ if (raw.length !== KEY_LEN) {
260
+ throw new KekUnavailableError(KIND_LIBSECRET, `Secret Service KEK has wrong length (got ${raw.length}, want ${KEY_LEN})`);
261
+ }
262
+ return raw;
263
+ }
264
+ catch (err) {
265
+ if (err instanceof KekUnavailableError)
266
+ throw err;
267
+ // secret-tool lookup exits 1 with empty stdout when the item is absent.
268
+ const stdout = err.stdout ?? '';
269
+ const isAbsent = stdout.trim().length === 0;
270
+ if (isAbsent)
271
+ return null;
272
+ throw new KekUnavailableError(KIND_LIBSECRET, 'secret-tool lookup failed', { cause: err });
273
+ }
274
+ }
275
+ /**
276
+ * Write the master KEK to the Secret Service. Uses stdin (base64-encoded)
277
+ * as required by `secret-tool store`. Idempotent — `secret-tool store`
278
+ * overwrites an existing item silently.
279
+ *
280
+ * `service` and `account` are passed in (rather than read from the
281
+ * module-level constants) so a `LinuxKek` instance constructed with an
282
+ * override stores under a unique Secret Service entry.
283
+ *
284
+ * Race note: see module-level comment above.
285
+ */
286
+ async function writeKekToSecretService(kek, service, account) {
287
+ if (kek.length !== KEY_LEN) {
288
+ throw new KekUnavailableError(KIND_LIBSECRET, `writeKekToSecretService: expected ${KEY_LEN}-byte buffer, got ${kek.length}`);
289
+ }
290
+ const b64 = kek.toString('base64');
291
+ try {
292
+ await exec('secret-tool', ['store', '--label=OverSky daemon master KEK', 'service', service, 'account', account], { input: b64 });
293
+ }
294
+ catch (err) {
295
+ throw new KekUnavailableError(KIND_LIBSECRET, 'secret-tool store failed', { cause: err });
296
+ }
297
+ }
298
+ // ---------------------------------------------------------------------------
299
+ // In-process KEK cache + tier selection.
300
+ // ---------------------------------------------------------------------------
301
+ /**
302
+ * In-process cache of the Tier-2 master KEK, keyed on
303
+ * `${service}::${account}` so multiple `LinuxKek` instances constructed
304
+ * with distinct service/account overrides do not collide. Loaded lazily
305
+ * on the first wrap/unwrap call per key. Cleared by
306
+ * `__resetKekCacheForTest()` so the test suite can simulate fresh-process
307
+ * state.
308
+ */
309
+ const _tier2KekCacheBySvc = new Map();
310
+ /**
311
+ * In-process cache of the Tier-3 master KEK. Single module-level buffer
312
+ * because Tier-3 derivation is deterministic per host — there is no
313
+ * service/account dimension to key on. Cleared alongside the Tier-2 map
314
+ * by `zeroizeLinuxKekCache()`.
315
+ */
316
+ let _tier3KekCache = null;
317
+ let _tierSelection = null;
318
+ /**
319
+ * Zero out and drop every in-process master-KEK cache buffer (both the
320
+ * Tier-2 by-service map and the Tier-3 single buffer).
321
+ *
322
+ * Must be called on:
323
+ * - Daemon shutdown (SIGTERM / SIGINT) so the KEK does not linger in
324
+ * memory across the process exit window where coredumps could land.
325
+ * - KEK rotation, after the new KEK has been resolved.
326
+ * - Logout flows that wipe at-rest credentials.
327
+ *
328
+ * Idempotent — safe to call when the caches are already empty.
329
+ */
330
+ export function zeroizeLinuxKekCache() {
331
+ for (const buf of _tier2KekCacheBySvc.values()) {
332
+ buf.fill(0);
333
+ }
334
+ _tier2KekCacheBySvc.clear();
335
+ if (_tier3KekCache !== null) {
336
+ _tier3KekCache.fill(0);
337
+ _tier3KekCache = null;
338
+ }
339
+ }
340
+ // Register the zeroize hook at module-load time so any caller that
341
+ // invokes `zeroizeKekCaches()` from `@skrr-ai/auth-core` (e.g. the
342
+ // daemon's graceful-shutdown handler) clears this cache without needing
343
+ // a direct import of linux.ts.
344
+ registerZeroizeHook(zeroizeLinuxKekCache);
345
+ /** @internal Test seam — clear the in-process master-KEK cache + tier
346
+ * selection. Routes through the production zeroize helper so the
347
+ * test-reset path exercises the same buffer-fill that ships in
348
+ * production, and additionally clears `_tierSelection` so a subsequent
349
+ * test re-probes (otherwise Tier-3 caching across tests poisons later
350
+ * cases). */
351
+ export function __resetKekCacheForTest() {
352
+ zeroizeLinuxKekCache();
353
+ _tierSelection = null;
354
+ }
355
+ /**
356
+ * Resolve the active tier exactly once per process. Tier-2 is preferred
357
+ * when libsecret is reachable; otherwise Tier-3 takes over if
358
+ * /etc/machine-id can be read. Throws `KekUnavailableError` only when
359
+ * NEITHER tier can satisfy a wrap.
360
+ */
361
+ async function resolveTier() {
362
+ if (_tierSelection !== null)
363
+ return _tierSelection;
364
+ if (await probeSecretTool()) {
365
+ _tierSelection = 'libsecret';
366
+ return _tierSelection;
367
+ }
368
+ // Tier-3 probe: derive once to confirm machine-id is present. Discard
369
+ // the derived key here — `resolveOrCreateKek` re-derives and caches it
370
+ // so we don't hold two KEK buffers in memory simultaneously.
371
+ try {
372
+ const probe = deriveTier3Kek();
373
+ probe.fill(0);
374
+ }
375
+ catch (err) {
376
+ if (err instanceof KekUnavailableError) {
377
+ throw new KekUnavailableError('linux', 'Linux KEK unavailable: secret-tool missing/D-Bus unreachable AND /etc/machine-id absent');
378
+ }
379
+ throw err;
380
+ }
381
+ _tierSelection = 'hkdf';
382
+ return _tierSelection;
383
+ }
384
+ /**
385
+ * Resolve the master KEK, generating + storing one on first use (Tier-2)
386
+ * or deriving deterministically (Tier-3). Cached after the first
387
+ * successful resolution.
388
+ *
389
+ * `service` and `account` only affect the Tier-2 path — they key the
390
+ * Tier-2 cache and are passed to the `secret-tool` shellout. Tier-3
391
+ * ignores them; its derivation depends solely on machine identity.
392
+ */
393
+ async function resolveOrCreateKek(service, account) {
394
+ const tier = await resolveTier();
395
+ if (tier === 'libsecret') {
396
+ const cacheKey = `${service}::${account}`;
397
+ const cached = _tier2KekCacheBySvc.get(cacheKey);
398
+ if (cached !== undefined)
399
+ return cached;
400
+ const existing = await readKekFromSecretService(service, account);
401
+ if (existing !== null) {
402
+ _tier2KekCacheBySvc.set(cacheKey, existing);
403
+ return existing;
404
+ }
405
+ const fresh = crypto.randomBytes(KEY_LEN);
406
+ await writeKekToSecretService(fresh, service, account);
407
+ _tier2KekCacheBySvc.set(cacheKey, fresh);
408
+ return fresh;
409
+ }
410
+ // Tier-3: deterministic derivation, no writeback. The KEK is
411
+ // recomputed on every cold start. Service/account are ignored —
412
+ // derivation depends solely on machine identity, so a single
413
+ // module-level cache suffices.
414
+ if (_tier3KekCache !== null)
415
+ return _tier3KekCache;
416
+ const derived = deriveTier3Kek();
417
+ _tier3KekCache = derived;
418
+ return derived;
419
+ }
420
+ /** Resolve an existing/deterministic KEK without persisting new native material. */
421
+ async function resolveExistingKek(service, account) {
422
+ const tier = await resolveTier();
423
+ if (tier === 'libsecret') {
424
+ const cacheKey = `${service}::${account}`;
425
+ const cached = _tier2KekCacheBySvc.get(cacheKey);
426
+ if (cached !== undefined)
427
+ return cached;
428
+ const existing = await readKekFromSecretService(service, account);
429
+ if (existing === null) {
430
+ throw new KekUnavailableError(KIND_LIBSECRET, 'Secret Service master KEK is missing; refusing to create it outside the enrollment lock');
431
+ }
432
+ _tier2KekCacheBySvc.set(cacheKey, existing);
433
+ return existing;
434
+ }
435
+ // Tier-3 is deterministic and performs no writeback, so deriving it is a
436
+ // read-only operation safe outside the enrollment mutex.
437
+ if (_tier3KekCache !== null)
438
+ return _tier3KekCache;
439
+ const derived = deriveTier3Kek();
440
+ _tier3KekCache = derived;
441
+ return derived;
442
+ }
443
+ // ---------------------------------------------------------------------------
444
+ // Shellout wrap / unwrap.
445
+ // ---------------------------------------------------------------------------
446
+ async function shelloutWrap(dek, aad, service, account) {
447
+ const kek = await resolveOrCreateKek(service, account);
448
+ const sealed = sealBuffer(dek, kek, aad);
449
+ return Buffer.from(envelopeSerialize(sealed), 'utf-8');
450
+ }
451
+ async function shelloutUnwrap(wrapped, aad, service, account, createIfMissing = true) {
452
+ const kek = await (createIfMissing
453
+ ? resolveOrCreateKek(service, account)
454
+ : resolveExistingKek(service, account));
455
+ const text = wrapped.toString('utf-8');
456
+ const sealed = envelopeDeserialize(text);
457
+ if (!sealed) {
458
+ throw new KekUnavailableError(KIND_LIBSECRET, 'wrapped blob is not a valid serialized envelope');
459
+ }
460
+ const out = openBuffer(sealed, kek, aad);
461
+ if (out.length !== KEY_LEN) {
462
+ throw new KekUnavailableError(KIND_LIBSECRET, `unwrapped DEK has wrong length (got ${out.length}, want ${KEY_LEN})`);
463
+ }
464
+ return out;
465
+ }
466
+ // ---------------------------------------------------------------------------
467
+ // LinuxKek — KekStrategy implementation.
468
+ // ---------------------------------------------------------------------------
469
+ export class LinuxKek {
470
+ id = 'linux-libsecret-machineid';
471
+ _lastKind = null;
472
+ service;
473
+ account;
474
+ /**
475
+ * Construct a Linux KEK strategy.
476
+ *
477
+ * @param opts.service Optional Secret Service `service` attribute used
478
+ * by Tier-2 `secret-tool lookup`/`store`. Defaults to
479
+ * `'ai.skrr.daemon.kek'`. Override only for test isolation /
480
+ * smoke tests; production callers should use the default so different
481
+ * daemon processes on the same host share the same KEK and can unwrap
482
+ * each other's persisted credentials. Ignored on the Tier-3 (HKDF)
483
+ * path. Callers that override are responsible for cleaning up the
484
+ * corresponding Secret Service entry when done.
485
+ * @param opts.account Optional Secret Service `account` attribute.
486
+ * Defaults to `'master-kek-v1'`. Same scoping rules as `service`.
487
+ */
488
+ constructor(opts) {
489
+ this.service = opts?.service ?? SS_SERVICE;
490
+ this.account = opts?.account ?? SS_ACCOUNT;
491
+ }
492
+ /**
493
+ * Telemetry kind of the most recent successful wrap/unwrap. `null` before
494
+ * any call. After the first successful wrap or unwrap, one of:
495
+ * - `'linux-libsecret-shellout'` (Tier-2)
496
+ * - `'linux-machineid-hkdf'` (Tier-3)
497
+ */
498
+ get kind() {
499
+ return this._lastKind;
500
+ }
501
+ /**
502
+ * `available()` is true on Linux when EITHER backend can satisfy a
503
+ * wrap: libsecret is reachable, or `/etc/machine-id` exists so Tier-3
504
+ * derivation can succeed. Probe results are cached for the process
505
+ * lifetime.
506
+ */
507
+ async available() {
508
+ if (process.platform !== 'linux')
509
+ return false;
510
+ if (await probeSecretTool())
511
+ return true;
512
+ // Tier-3 fallback: machine-id presence is sufficient.
513
+ try {
514
+ const probe = deriveTier3Kek();
515
+ probe.fill(0);
516
+ return true;
517
+ }
518
+ catch {
519
+ return false;
520
+ }
521
+ }
522
+ async wrap(dek, aad) {
523
+ if (process.platform !== 'linux') {
524
+ throw new KekUnavailableError(this.id, 'Linux KEK called on non-linux platform');
525
+ }
526
+ const aadBuf = aad ?? Buffer.alloc(0);
527
+ const tier = await resolveTier();
528
+ try {
529
+ const out = await shelloutWrap(dek, aadBuf, this.service, this.account);
530
+ this._lastKind = tier === 'libsecret' ? KIND_LIBSECRET : KIND_HKDF;
531
+ return out;
532
+ }
533
+ catch (err) {
534
+ if (err instanceof KekUnavailableError)
535
+ throw err;
536
+ throw new KekUnavailableError(this.id, err?.message ?? 'shellout wrap failed', {
537
+ cause: err,
538
+ });
539
+ }
540
+ }
541
+ async unwrap(wrapped, aad) {
542
+ if (process.platform !== 'linux') {
543
+ throw new KekUnavailableError(this.id, 'Linux KEK called on non-linux platform');
544
+ }
545
+ const aadBuf = aad ?? Buffer.alloc(0);
546
+ const tier = await resolveTier();
547
+ try {
548
+ const out = await shelloutUnwrap(wrapped, aadBuf, this.service, this.account);
549
+ this._lastKind = tier === 'libsecret' ? KIND_LIBSECRET : KIND_HKDF;
550
+ return out;
551
+ }
552
+ catch (err) {
553
+ if (err instanceof KekUnavailableError)
554
+ throw err;
555
+ // Pass cipher errors (tag mismatch, malformed blob) through without
556
+ // swallowing — credEnvelope.ts decides fail-open vs fail-closed.
557
+ throw err;
558
+ }
559
+ }
560
+ async unwrapExisting(wrapped, aad) {
561
+ if (process.platform !== 'linux') {
562
+ throw new KekUnavailableError(this.id, 'Linux KEK called on non-linux platform');
563
+ }
564
+ const aadBuf = aad ?? Buffer.alloc(0);
565
+ const tier = await resolveTier();
566
+ const out = await shelloutUnwrap(wrapped, aadBuf, this.service, this.account, false);
567
+ this._lastKind = tier === 'libsecret' ? KIND_LIBSECRET : KIND_HKDF;
568
+ return out;
569
+ }
570
+ describe() {
571
+ if (process.platform !== 'linux') {
572
+ return 'Linux libsecret + machine-id KEK (not linux — unavailable)';
573
+ }
574
+ if (_tierSelection === 'libsecret') {
575
+ return 'Linux Secret Service via secret-tool shellout (libsecret / gnome-keyring / ksecretservice)';
576
+ }
577
+ if (_tierSelection === 'hkdf') {
578
+ return 'Linux machine-id HKDF KEK (Tier-3 fallback — backup-theft floor only)';
579
+ }
580
+ if (_secretToolProbeCache === false) {
581
+ return 'Linux KEK (Tier-3 candidate — call available() to materialize)';
582
+ }
583
+ return 'Linux KEK (probe pending — call available() to materialize)';
584
+ }
585
+ }