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