@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,111 @@
1
+ /**
2
+ * kek/types.ts — platform-strategy interface for the Key Encryption Key.
3
+ *
4
+ * The KEK is the root of the at-rest encryption hierarchy. It MUST live
5
+ * in a platform-native store (Secure Enclave, DPAPI, libsecret +
6
+ * machine-id-derived material) so that an attacker who exfiltrates the
7
+ * on-disk wrapped DEK cannot brute-force it offline.
8
+ *
9
+ * Each platform implementation owns its own native binding and key
10
+ * lifecycle. They share only this interface, which is deliberately small
11
+ * to make swapping implementations cheap as native bindings ship.
12
+ *
13
+ * Failure mode contract — every implementation MUST:
14
+ * - Return `available()` honestly. If `available()` is true, `wrap()`
15
+ * and `unwrap()` are expected to succeed under normal conditions.
16
+ * - On `available()` returning false, the daemon falls through to the
17
+ * legacy plaintext backend with a `cred_envelope.kek_unavailable`
18
+ * telemetry event. NEVER crash the daemon because a KEK is missing.
19
+ * - Throw on unwrap with the wrong key/AAD; never silently return
20
+ * garbage. The cipher path inside `credentialEnvelope.ts` already
21
+ * enforces this; native impls just have to surface that error.
22
+ */
23
+ /**
24
+ * Platform-bound key for wrapping/unwrapping the per-profile DEK.
25
+ *
26
+ * Implementations are per-platform but the API is platform-agnostic so
27
+ * the daemon's integration code stays platform-blind.
28
+ */
29
+ export interface KekStrategy {
30
+ /**
31
+ * Stable lowercase identifier for telemetry, logs, and dashboard
32
+ * filtering. Format: `<platform>-<backend>` e.g. `macos-secure-enclave`,
33
+ * `linux-libsecret`, `windows-dpapi`. Stable across versions; new
34
+ * backends get new ids.
35
+ */
36
+ readonly id: string;
37
+ /**
38
+ * Optional finer-grained backend identifier for strategies that select
39
+ * between multiple implementations at runtime (e.g. macOS NAPI binding
40
+ * vs. `security(1)` shellout). Stable lowercase string —
41
+ * `macos-secure-enclave-napi`, `macos-keychain-shellout`. `null` until
42
+ * the first wrap/unwrap completes; older callers can ignore this
43
+ * entirely. See `docs/daemon-auth-l12-kek-platform-bindings.md`.
44
+ */
45
+ readonly kind?: string | null;
46
+ /**
47
+ * Fast probe — must NOT throw, NOT prompt the user, and NOT exceed
48
+ * ~50ms in the success case. Used at daemon startup to decide whether
49
+ * to enable the envelope path.
50
+ *
51
+ * Returning true is a promise that the wrap/unwrap calls will succeed
52
+ * under normal conditions; if those subsequently fail, the daemon
53
+ * falls back to legacy plaintext + emits `cred_envelope.wrap.failure`.
54
+ */
55
+ available(): Promise<boolean>;
56
+ /**
57
+ * Wrap a 32-byte DEK under the platform-bound KEK. Returns opaque
58
+ * platform-specific bytes the caller persists alongside the wrapped
59
+ * credentials. The format is private to the implementation; never
60
+ * inspected by the daemon.
61
+ *
62
+ * The optional `aad` is bound into the wrap when the platform's AEAD
63
+ * supports it. Linux/Windows backends MUST honor it; macOS Secure
64
+ * Enclave's ECIES variant has limited AAD support — implementations
65
+ * may emulate AAD by deriving a per-context KEK via HKDF when the
66
+ * primitive doesn't support AAD natively.
67
+ */
68
+ wrap(dek: Buffer, aad?: Buffer): Promise<Buffer>;
69
+ /**
70
+ * Unwrap a previously-wrapped DEK. Throws on:
71
+ * - Wrong KEK / AAD mismatch (treat as a hard authentication failure)
72
+ * - Corrupted wrapped bytes
73
+ * - Platform-side errors (keychain locked, Secure Enclave busy, etc.)
74
+ *
75
+ * Callers should catch and emit `cred_envelope.unwrap.fallback_to_legacy`
76
+ * before falling through to the plaintext path.
77
+ */
78
+ unwrap(wrapped: Buffer, aad?: Buffer): Promise<Buffer>;
79
+ /**
80
+ * Optional read-only unwrap used by the credential-envelope optimistic
81
+ * startup path. Implementations MUST NOT create, rotate, or persist native
82
+ * KEK material from this method. If the native key is missing, throw and let
83
+ * the caller retry `unwrap()` under the cross-process enrollment mutex.
84
+ *
85
+ * Strategies that cannot guarantee this invariant leave the method absent;
86
+ * callers then take the mutex before any unwrap attempt.
87
+ */
88
+ unwrapExisting?(wrapped: Buffer, aad?: Buffer): Promise<Buffer>;
89
+ /**
90
+ * Human-readable description for the `oversky status` / diagnostics
91
+ * page. Includes platform-specific hints ("Secure Enclave with
92
+ * biometry", "DPAPI user scope", "machine-id-derived KEK with
93
+ * libsecret store"). Never includes secret material.
94
+ */
95
+ describe(): string;
96
+ }
97
+ /**
98
+ * Sentinel error class used by KEK implementations to signal
99
+ * unrecoverable native failures (e.g. Secure Enclave key invalidated by
100
+ * a Touch ID change). The daemon's integration treats these as
101
+ * `available=false` going forward and re-probes on the next startup.
102
+ *
103
+ * Distinct from generic `Error` so the integration's catch can decide
104
+ * whether to fall back permanently or just for this request.
105
+ */
106
+ export declare class KekUnavailableError extends Error {
107
+ readonly id: string;
108
+ constructor(id: string, message: string, opts?: {
109
+ cause?: unknown;
110
+ });
111
+ }
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ /**
3
+ * kek/types.ts — platform-strategy interface for the Key Encryption Key.
4
+ *
5
+ * The KEK is the root of the at-rest encryption hierarchy. It MUST live
6
+ * in a platform-native store (Secure Enclave, DPAPI, libsecret +
7
+ * machine-id-derived material) so that an attacker who exfiltrates the
8
+ * on-disk wrapped DEK cannot brute-force it offline.
9
+ *
10
+ * Each platform implementation owns its own native binding and key
11
+ * lifecycle. They share only this interface, which is deliberately small
12
+ * to make swapping implementations cheap as native bindings ship.
13
+ *
14
+ * Failure mode contract — every implementation MUST:
15
+ * - Return `available()` honestly. If `available()` is true, `wrap()`
16
+ * and `unwrap()` are expected to succeed under normal conditions.
17
+ * - On `available()` returning false, the daemon falls through to the
18
+ * legacy plaintext backend with a `cred_envelope.kek_unavailable`
19
+ * telemetry event. NEVER crash the daemon because a KEK is missing.
20
+ * - Throw on unwrap with the wrong key/AAD; never silently return
21
+ * garbage. The cipher path inside `credentialEnvelope.ts` already
22
+ * enforces this; native impls just have to surface that error.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.KekUnavailableError = void 0;
26
+ /**
27
+ * Sentinel error class used by KEK implementations to signal
28
+ * unrecoverable native failures (e.g. Secure Enclave key invalidated by
29
+ * a Touch ID change). The daemon's integration treats these as
30
+ * `available=false` going forward and re-probes on the next startup.
31
+ *
32
+ * Distinct from generic `Error` so the integration's catch can decide
33
+ * whether to fall back permanently or just for this request.
34
+ */
35
+ class KekUnavailableError extends Error {
36
+ id;
37
+ constructor(id, message, opts) {
38
+ super(message, opts);
39
+ this.name = 'KekUnavailableError';
40
+ this.id = id;
41
+ }
42
+ }
43
+ exports.KekUnavailableError = KekUnavailableError;
@@ -0,0 +1,71 @@
1
+ import type { KekStrategy } from './types.js';
2
+ declare const KIND = "windows-dpapi-powershell-shellout";
3
+ /**
4
+ * @internal Test seam — inject a mock execFile for hermetic tests so the
5
+ * suite never touches the real PowerShell or DPAPI. Reset to `null` to
6
+ * restore the real `child_process.execFile`.
7
+ */
8
+ type ExecFileLike = (file: string, args: readonly string[], options?: {
9
+ encoding?: BufferEncoding;
10
+ }) => Promise<{
11
+ stdout: string;
12
+ stderr: string;
13
+ }>;
14
+ export declare function __setExecFileForTest(fn: ExecFileLike | null): void;
15
+ /**
16
+ * @internal Test seam — no-op for Windows since DPAPI is stateless on
17
+ * the library side (no in-process master-blob to clear). Exists for API
18
+ * symmetry with `__resetKekCacheForTest` on macOS so test harness setup
19
+ * code can call the same pattern across platforms.
20
+ */
21
+ export declare function __resetMasterBlobForTest(): void;
22
+ export declare class WindowsKek implements KekStrategy {
23
+ readonly id = "windows-dpapi";
24
+ /**
25
+ * Telemetry kind of the most recent successful wrap/unwrap. `null`
26
+ * before any call. Always `'windows-dpapi-powershell-shellout'` for
27
+ * this Tier-2 implementation.
28
+ */
29
+ private _lastKind;
30
+ get kind(): typeof KIND | null;
31
+ /**
32
+ * Returns true when PowerShell is on PATH and the DPAPI assembly loads.
33
+ *
34
+ * Result is cached for the process lifetime. On probe failure (ENOENT,
35
+ * empty stdout, PowerShell Constrained Language Mode blocking the type),
36
+ * returns false and the daemon falls through to the legacy plaintext
37
+ * backend.
38
+ */
39
+ available(): Promise<boolean>;
40
+ /**
41
+ * Wrap `dek` using Windows DPAPI with `aad` as optional entropy.
42
+ *
43
+ * IMPORTANT: DPAPI IS the wrap — there is no separate AES-GCM layer.
44
+ * The returned bytes are the raw DPAPI ciphertext blob and should be
45
+ * persisted as-is (e.g. to a 0600 file under the profile config dir).
46
+ *
47
+ * The `aad` buffer acts as DPAPI's `optionalEntropy`. Must match
48
+ * exactly at unwrap time. If `aad` is omitted or empty, `$null`
49
+ * entropy is used — still bound to user identity, but without the
50
+ * extra entropy protection.
51
+ *
52
+ * @throws {KekUnavailableError} on non-win32 platform or PowerShell failure.
53
+ */
54
+ wrap(dek: Buffer, aad?: Buffer): Promise<Buffer>;
55
+ /**
56
+ * Unwrap a previously-wrapped DEK using Windows DPAPI.
57
+ *
58
+ * The `aad` buffer must match exactly what was passed to `wrap()`.
59
+ * DPAPI CryptographicException (mismatched entropy, corrupted blob,
60
+ * master-key roll after user-profile reset) surfaces as a plain `Error`
61
+ * so that upstream `credEnvelope.ts` can map it to the `kek_rotated`
62
+ * path and force re-enrollment.
63
+ *
64
+ * @throws {Error} on DPAPI CryptographicException (kek_rotated path).
65
+ * @throws {KekUnavailableError} on non-win32 or PowerShell invocation failure.
66
+ */
67
+ unwrap(wrapped: Buffer, aad?: Buffer): Promise<Buffer>;
68
+ unwrapExisting(wrapped: Buffer, aad?: Buffer): Promise<Buffer>;
69
+ describe(): string;
70
+ }
71
+ export {};
@@ -0,0 +1,350 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WindowsKek = void 0;
4
+ exports.__setExecFileForTest = __setExecFileForTest;
5
+ exports.__resetMasterBlobForTest = __resetMasterBlobForTest;
6
+ /**
7
+ * kek/windows.ts — Windows DPAPI KEK strategy.
8
+ *
9
+ * ARCHITECTURAL NOTE — DPAPI IS the wrap, not a key store:
10
+ *
11
+ * Unlike the macOS path (which keeps a 32-byte KEK in the Keychain and
12
+ * then AES-256-GCM-wraps the DEK with that KEK), Windows DPAPI is
13
+ * structurally different. `ProtectedData.Protect(plaintext, entropy, scope)`
14
+ * returns a self-contained ciphertext blob that you persist yourself.
15
+ * There is no named persistent credential to load; the "KEK" is implicit
16
+ * in the user's Windows profile master key, derived from login credentials.
17
+ *
18
+ * As a result:
19
+ * - `wrap(dek, aad)` calls DPAPI directly with `optionalEntropy = aad`.
20
+ * - `unwrap(wrapped, aad)` calls DPAPI with the same entropy.
21
+ * - The bytes returned to `credEnvelope.ts` ARE the DPAPI ciphertext;
22
+ * there is no separate AES-GCM layer on top.
23
+ * - Do NOT attempt to unify this with the macOS `credentialEnvelope.ts`
24
+ * AES-GCM layer. The primitives are different by design.
25
+ *
26
+ * Security model (CurrentUser scope — mandatory):
27
+ * - DPAPI binds the ciphertext to the Windows user identity via the
28
+ * user's login-derived master key. A different Windows account on the
29
+ * same machine CANNOT decrypt the blob.
30
+ * - `DataProtectionScope.CurrentUser` is always used. `LocalMachine`
31
+ * scope is explicitly NOT used: it would allow any account on the box
32
+ * to decrypt — violating cross-user isolation.
33
+ * - `optionalEntropy` = the `aad` buffer acts as AAD-equivalent:
34
+ * must match exactly at unwrap time.
35
+ *
36
+ * Backend — PowerShell shellout:
37
+ * PowerShell is available on every Windows installation since Win 7+.
38
+ * No extra native dependencies are required, matching the project
39
+ * precedent in `daemon/src/keychain.ts`. A NAPI binding can replace
40
+ * this in a future Phase D for enterprise hardened environments
41
+ * (PowerShell Constrained Language Mode blocks the DPAPI assembly load;
42
+ * see `available()` — probe will return false in those environments).
43
+ *
44
+ * Argument size limit:
45
+ * PowerShell `-Command` is bounded at roughly 32 KB. A 32-byte DEK and
46
+ * small AAD labels are well within that limit. Document and enforce at
47
+ * the call site if callers begin passing large AAD in a future revision.
48
+ *
49
+ * Edge cases (documented; not tested in this PR):
50
+ * - CRYPTPROTECT master-key roll (user profile reset): `Unprotect` throws
51
+ * `CryptographicException` → surfaces as plain `Error` → upstream
52
+ * `credEnvelope.ts` treats as `kek_rotated` → forces re-enrollment.
53
+ * - Roaming profiles: `CurrentUser` scope is user-bound + machine-local,
54
+ * so a roamed login on a new box re-runs first-time enrollment naturally.
55
+ * - PowerShell Constrained Language Mode: probe will return false since
56
+ * `[System.Security.Cryptography.ProtectedData]` access is blocked.
57
+ * `available()` correctly reflects this as unavailable.
58
+ */
59
+ const node_child_process_1 = require("node:child_process");
60
+ const node_util_1 = require("node:util");
61
+ const types_js_1 = require("./types.js");
62
+ const KIND = 'windows-dpapi-powershell-shellout';
63
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
64
+ let _execFileForTest = null;
65
+ function __setExecFileForTest(fn) {
66
+ _execFileForTest = fn;
67
+ // Reset the probe cache so the next available() re-evaluates with the mock.
68
+ _powershellProbeCache = undefined;
69
+ }
70
+ /** Default execFile wrapper used when no test override is installed. */
71
+ const defaultExec = async (file, args, options) => {
72
+ const result = await execFileAsync(file, args, {
73
+ encoding: 'utf-8',
74
+ ...(options ?? {}),
75
+ });
76
+ // Defensive coercion — when callers override the encoding, stdout may
77
+ // surface as Buffer/Uint8Array; the typeof guard keeps both shapes safe.
78
+ return {
79
+ stdout: typeof result.stdout === 'string'
80
+ ? result.stdout
81
+ : Buffer.from(result.stdout).toString('utf-8'),
82
+ stderr: typeof result.stderr === 'string'
83
+ ? result.stderr
84
+ : Buffer.from(result.stderr).toString('utf-8'),
85
+ };
86
+ };
87
+ function exec(file, args) {
88
+ const impl = _execFileForTest ?? defaultExec;
89
+ return impl(file, args, { encoding: 'utf-8' });
90
+ }
91
+ // ---------------------------------------------------------------------------
92
+ // Probe — cached for the process lifetime to keep available() ~O(1).
93
+ // ---------------------------------------------------------------------------
94
+ /** `undefined` = not yet probed; `boolean` = cached probe outcome. */
95
+ let _powershellProbeCache;
96
+ /**
97
+ * Probe whether PowerShell and the DPAPI assembly are accessible.
98
+ *
99
+ * Checks that `[System.Security.Cryptography.ProtectedData]` resolves —
100
+ * a non-empty stdout means the type loaded and DPAPI is usable.
101
+ * Exit 0 with empty stdout, non-zero exit, or ENOENT → false.
102
+ *
103
+ * Enterprise environments with PowerShell Constrained Language Mode will
104
+ * fail here because the ProtectedData type is blocked in that mode.
105
+ */
106
+ async function probePowerShell() {
107
+ if (process.platform !== 'win32')
108
+ return false;
109
+ if (_powershellProbeCache !== undefined)
110
+ return _powershellProbeCache;
111
+ try {
112
+ const { stdout } = await exec('powershell.exe', [
113
+ '-NoProfile',
114
+ '-NonInteractive',
115
+ '-Command',
116
+ '[System.Security.Cryptography.ProtectedData].FullName',
117
+ ]);
118
+ _powershellProbeCache = stdout.trim().length > 0;
119
+ }
120
+ catch {
121
+ _powershellProbeCache = false;
122
+ }
123
+ return _powershellProbeCache;
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // DPAPI shellout helpers.
127
+ // ---------------------------------------------------------------------------
128
+ /**
129
+ * Call `ProtectedData.Protect` via PowerShell.
130
+ *
131
+ * Both `plaintext` and `entropy` are passed as base64 strings inside the
132
+ * PowerShell command. Base64 chars (`A-Za-z0-9+/=`) are safe inside
133
+ * PowerShell single-quoted strings — no escaping needed.
134
+ *
135
+ * Scope: `CurrentUser` — mandatory for cross-user isolation.
136
+ *
137
+ * @param plaintext - The bytes to protect (the DEK).
138
+ * @param entropy - Optional entropy / AAD-equivalent. Empty Buffer → $null.
139
+ * @returns DPAPI ciphertext as a Buffer.
140
+ * @throws {KekUnavailableError} on any PowerShell or DPAPI failure.
141
+ */
142
+ async function dpapiProtect(plaintext, entropy) {
143
+ const ptB64 = plaintext.toString('base64');
144
+ const enB64 = entropy.length > 0 ? entropy.toString('base64') : null;
145
+ const entropyExpr = enB64 !== null ? `$en = [Convert]::FromBase64String('${enB64}');` : '$en = $null;';
146
+ const command = [
147
+ `$pt = [Convert]::FromBase64String('${ptB64}');`,
148
+ entropyExpr,
149
+ '$ct = [System.Security.Cryptography.ProtectedData]::Protect($pt, $en, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);',
150
+ '[Convert]::ToBase64String($ct)',
151
+ ].join(' ');
152
+ let stdout;
153
+ let stderr;
154
+ try {
155
+ ({ stdout, stderr } = await exec('powershell.exe', [
156
+ '-NoProfile',
157
+ '-NonInteractive',
158
+ '-Command',
159
+ command,
160
+ ]));
161
+ }
162
+ catch (err) {
163
+ throw new types_js_1.KekUnavailableError(KIND, `DPAPI Protect failed: ${err.message}`, {
164
+ cause: err,
165
+ });
166
+ }
167
+ if (stderr && stderr.includes('CryptographicException')) {
168
+ throw new types_js_1.KekUnavailableError(KIND, `DPAPI Protect CryptographicException: ${stderr.trim()}`);
169
+ }
170
+ const trimmed = stdout.trim();
171
+ if (trimmed.length === 0) {
172
+ throw new types_js_1.KekUnavailableError(KIND, 'DPAPI Protect returned empty output');
173
+ }
174
+ try {
175
+ return Buffer.from(trimmed, 'base64');
176
+ }
177
+ catch (err) {
178
+ throw new types_js_1.KekUnavailableError(KIND, 'DPAPI Protect output is not valid base64', { cause: err });
179
+ }
180
+ }
181
+ /**
182
+ * Call `ProtectedData.Unprotect` via PowerShell.
183
+ *
184
+ * DPAPI failure (e.g. master-key roll after user-profile reset,
185
+ * `CryptographicException`) is surfaced as a plain `Error` (NOT
186
+ * `KekUnavailableError`) so that upstream `credEnvelope.ts` can map the
187
+ * failure to the `kek_rotated` path and force re-enrollment rather than
188
+ * permanently disabling the KEK.
189
+ *
190
+ * @param wrapped - DPAPI ciphertext produced by `dpapiProtect`.
191
+ * @param entropy - Must match the entropy used at protect time exactly.
192
+ * @returns Plaintext bytes (the DEK).
193
+ * @throws {Error} on DPAPI CryptographicException (mismatched key/entropy,
194
+ * corrupted blob, master-key roll). NOT KekUnavailableError — see above.
195
+ * @throws {KekUnavailableError} on PowerShell invocation failure unrelated
196
+ * to DPAPI crypto (binary missing, empty output, base64 decode error).
197
+ */
198
+ async function dpapiUnprotect(wrapped, entropy) {
199
+ const wrappedB64 = wrapped.toString('base64');
200
+ const enB64 = entropy.length > 0 ? entropy.toString('base64') : null;
201
+ const entropyExpr = enB64 !== null ? `$en = [Convert]::FromBase64String('${enB64}');` : '$en = $null;';
202
+ const command = [
203
+ `$ct = [Convert]::FromBase64String('${wrappedB64}');`,
204
+ entropyExpr,
205
+ '$pt = [System.Security.Cryptography.ProtectedData]::Unprotect($ct, $en, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);',
206
+ '[Convert]::ToBase64String($pt)',
207
+ ].join(' ');
208
+ let stdout;
209
+ let stderr;
210
+ try {
211
+ ({ stdout, stderr } = await exec('powershell.exe', [
212
+ '-NoProfile',
213
+ '-NonInteractive',
214
+ '-Command',
215
+ command,
216
+ ]));
217
+ }
218
+ catch (err) {
219
+ // Non-zero exit from PowerShell — check for CryptographicException
220
+ // which signals a DPAPI failure (mismatched key, corrupted blob, etc.).
221
+ const msg = err.message ?? '';
222
+ const stderrStr = err.stderr ?? '';
223
+ if (msg.includes('CryptographicException') || stderrStr.includes('CryptographicException')) {
224
+ // Plain Error so upstream maps to kek_rotated, not kek_unavailable.
225
+ throw new Error(`DPAPI Unprotect CryptographicException: ${stderrStr || msg}`);
226
+ }
227
+ throw new types_js_1.KekUnavailableError(KIND, `DPAPI Unprotect failed: ${msg}`, { cause: err });
228
+ }
229
+ if (stderr && stderr.includes('CryptographicException')) {
230
+ // Surfaces on some PowerShell versions via stderr without non-zero exit.
231
+ throw new Error(`DPAPI Unprotect CryptographicException: ${stderr.trim()}`);
232
+ }
233
+ const trimmed = stdout.trim();
234
+ if (trimmed.length === 0) {
235
+ throw new types_js_1.KekUnavailableError(KIND, 'DPAPI Unprotect returned empty output');
236
+ }
237
+ try {
238
+ return Buffer.from(trimmed, 'base64');
239
+ }
240
+ catch (err) {
241
+ throw new types_js_1.KekUnavailableError(KIND, 'DPAPI Unprotect output is not valid base64', {
242
+ cause: err,
243
+ });
244
+ }
245
+ }
246
+ // ---------------------------------------------------------------------------
247
+ // WindowsKek — KekStrategy implementation.
248
+ // ---------------------------------------------------------------------------
249
+ /**
250
+ * @internal Test seam — no-op for Windows since DPAPI is stateless on
251
+ * the library side (no in-process master-blob to clear). Exists for API
252
+ * symmetry with `__resetKekCacheForTest` on macOS so test harness setup
253
+ * code can call the same pattern across platforms.
254
+ */
255
+ function __resetMasterBlobForTest() {
256
+ // DPAPI is self-contained per-blob; there is no in-process master-KEK
257
+ // cache to clear. This seam is intentionally a no-op.
258
+ _powershellProbeCache = undefined;
259
+ }
260
+ class WindowsKek {
261
+ id = 'windows-dpapi';
262
+ /**
263
+ * Telemetry kind of the most recent successful wrap/unwrap. `null`
264
+ * before any call. Always `'windows-dpapi-powershell-shellout'` for
265
+ * this Tier-2 implementation.
266
+ */
267
+ _lastKind = null;
268
+ get kind() {
269
+ return this._lastKind;
270
+ }
271
+ /**
272
+ * Returns true when PowerShell is on PATH and the DPAPI assembly loads.
273
+ *
274
+ * Result is cached for the process lifetime. On probe failure (ENOENT,
275
+ * empty stdout, PowerShell Constrained Language Mode blocking the type),
276
+ * returns false and the daemon falls through to the legacy plaintext
277
+ * backend.
278
+ */
279
+ async available() {
280
+ return probePowerShell();
281
+ }
282
+ /**
283
+ * Wrap `dek` using Windows DPAPI with `aad` as optional entropy.
284
+ *
285
+ * IMPORTANT: DPAPI IS the wrap — there is no separate AES-GCM layer.
286
+ * The returned bytes are the raw DPAPI ciphertext blob and should be
287
+ * persisted as-is (e.g. to a 0600 file under the profile config dir).
288
+ *
289
+ * The `aad` buffer acts as DPAPI's `optionalEntropy`. Must match
290
+ * exactly at unwrap time. If `aad` is omitted or empty, `$null`
291
+ * entropy is used — still bound to user identity, but without the
292
+ * extra entropy protection.
293
+ *
294
+ * @throws {KekUnavailableError} on non-win32 platform or PowerShell failure.
295
+ */
296
+ async wrap(dek, aad) {
297
+ if (process.platform !== 'win32') {
298
+ throw new types_js_1.KekUnavailableError(this.id, 'Windows DPAPI KEK called on non-win32 platform');
299
+ }
300
+ if (!(await probePowerShell())) {
301
+ throw new types_js_1.KekUnavailableError(this.id, 'Windows DPAPI KEK unavailable: PowerShell or DPAPI assembly not accessible');
302
+ }
303
+ const aadBuf = aad ?? Buffer.alloc(0);
304
+ const out = await dpapiProtect(dek, aadBuf);
305
+ this._lastKind = KIND;
306
+ return out;
307
+ }
308
+ /**
309
+ * Unwrap a previously-wrapped DEK using Windows DPAPI.
310
+ *
311
+ * The `aad` buffer must match exactly what was passed to `wrap()`.
312
+ * DPAPI CryptographicException (mismatched entropy, corrupted blob,
313
+ * master-key roll after user-profile reset) surfaces as a plain `Error`
314
+ * so that upstream `credEnvelope.ts` can map it to the `kek_rotated`
315
+ * path and force re-enrollment.
316
+ *
317
+ * @throws {Error} on DPAPI CryptographicException (kek_rotated path).
318
+ * @throws {KekUnavailableError} on non-win32 or PowerShell invocation failure.
319
+ */
320
+ async unwrap(wrapped, aad) {
321
+ if (process.platform !== 'win32') {
322
+ throw new types_js_1.KekUnavailableError(this.id, 'Windows DPAPI KEK called on non-win32 platform');
323
+ }
324
+ if (!(await probePowerShell())) {
325
+ throw new types_js_1.KekUnavailableError(this.id, 'Windows DPAPI KEK unavailable: PowerShell or DPAPI assembly not accessible');
326
+ }
327
+ const aadBuf = aad ?? Buffer.alloc(0);
328
+ const out = await dpapiUnprotect(wrapped, aadBuf);
329
+ this._lastKind = KIND;
330
+ return out;
331
+ }
332
+ // DPAPI owns its master-key lifecycle; unprotect never creates application
333
+ // KEK material, so the normal unwrap is safe for the optimistic read path.
334
+ async unwrapExisting(wrapped, aad) {
335
+ return this.unwrap(wrapped, aad);
336
+ }
337
+ describe() {
338
+ if (process.platform !== 'win32') {
339
+ return 'Windows DPAPI KEK (not win32 — unavailable)';
340
+ }
341
+ if (_powershellProbeCache === true) {
342
+ return 'Windows DPAPI (CurrentUser) via PowerShell shellout';
343
+ }
344
+ if (_powershellProbeCache === false) {
345
+ return 'Windows DPAPI KEK unavailable (PowerShell or DPAPI assembly inaccessible)';
346
+ }
347
+ return 'Windows DPAPI KEK (probe pending — call available() to materialize)';
348
+ }
349
+ }
350
+ exports.WindowsKek = WindowsKek;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * kek/zeroize-registry.ts — pluggable shutdown/rotation zeroize hooks.
3
+ *
4
+ * Each platform KEK module that holds in-process secret material (today
5
+ * only macOS's `_kekCache`; Linux and Windows when their cache layers
6
+ * land) calls `registerZeroizeHook(fn)` from its module body. The daemon
7
+ * (or any auth-core caller) invokes `zeroizeKekCaches()` on shutdown,
8
+ * KEK rotation, or logout to fire every registered hook.
9
+ *
10
+ * Lives in its own file (rather than inside `kek/index.ts`) so platform
11
+ * modules can import the registry without pulling in the cross-platform
12
+ * selector that re-exports them — avoids a circular import.
13
+ *
14
+ * Hook contract:
15
+ * - Must be synchronous (zeroization is a buffer.fill — no I/O).
16
+ * - Must be idempotent — safe to call when the cache is empty.
17
+ * - Must NOT throw. Any error inside a hook is swallowed so a partial
18
+ * failure on one platform's hook never blocks zeroization on the
19
+ * others.
20
+ * - Registration is process-lifetime; there is no de-register path
21
+ * because every caller is a module-scope singleton.
22
+ */
23
+ type ZeroizeHook = () => void;
24
+ /**
25
+ * Register a zeroize hook. Called at module-load time from each platform
26
+ * KEK module that owns a cache buffer. Idempotent against the SAME
27
+ * function reference (does not re-add) so a test that re-imports the
28
+ * module does not stack duplicates.
29
+ */
30
+ export declare function registerZeroizeHook(fn: ZeroizeHook): void;
31
+ /**
32
+ * Fire every registered zeroize hook. Errors are swallowed — zeroization
33
+ * is best-effort; a failure to clear one platform's cache must not block
34
+ * the others, and there is no useful recovery path for the caller.
35
+ *
36
+ * Daemon callers should invoke this on:
37
+ * - SIGTERM / SIGINT (graceful shutdown)
38
+ * - KEK rotation (after the new wrap is materialized)
39
+ * - Logout (after at-rest credentials are wiped)
40
+ */
41
+ export declare function zeroizeKekCaches(): void;
42
+ /** @internal Test seam — drop all registered hooks. */
43
+ export declare function __resetZeroizeRegistryForTest(): void;
44
+ export {};
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ /**
3
+ * kek/zeroize-registry.ts — pluggable shutdown/rotation zeroize hooks.
4
+ *
5
+ * Each platform KEK module that holds in-process secret material (today
6
+ * only macOS's `_kekCache`; Linux and Windows when their cache layers
7
+ * land) calls `registerZeroizeHook(fn)` from its module body. The daemon
8
+ * (or any auth-core caller) invokes `zeroizeKekCaches()` on shutdown,
9
+ * KEK rotation, or logout to fire every registered hook.
10
+ *
11
+ * Lives in its own file (rather than inside `kek/index.ts`) so platform
12
+ * modules can import the registry without pulling in the cross-platform
13
+ * selector that re-exports them — avoids a circular import.
14
+ *
15
+ * Hook contract:
16
+ * - Must be synchronous (zeroization is a buffer.fill — no I/O).
17
+ * - Must be idempotent — safe to call when the cache is empty.
18
+ * - Must NOT throw. Any error inside a hook is swallowed so a partial
19
+ * failure on one platform's hook never blocks zeroization on the
20
+ * others.
21
+ * - Registration is process-lifetime; there is no de-register path
22
+ * because every caller is a module-scope singleton.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.registerZeroizeHook = registerZeroizeHook;
26
+ exports.zeroizeKekCaches = zeroizeKekCaches;
27
+ exports.__resetZeroizeRegistryForTest = __resetZeroizeRegistryForTest;
28
+ const _hooks = [];
29
+ /**
30
+ * Register a zeroize hook. Called at module-load time from each platform
31
+ * KEK module that owns a cache buffer. Idempotent against the SAME
32
+ * function reference (does not re-add) so a test that re-imports the
33
+ * module does not stack duplicates.
34
+ */
35
+ function registerZeroizeHook(fn) {
36
+ if (_hooks.indexOf(fn) === -1) {
37
+ _hooks.push(fn);
38
+ }
39
+ }
40
+ /**
41
+ * Fire every registered zeroize hook. Errors are swallowed — zeroization
42
+ * is best-effort; a failure to clear one platform's cache must not block
43
+ * the others, and there is no useful recovery path for the caller.
44
+ *
45
+ * Daemon callers should invoke this on:
46
+ * - SIGTERM / SIGINT (graceful shutdown)
47
+ * - KEK rotation (after the new wrap is materialized)
48
+ * - Logout (after at-rest credentials are wiped)
49
+ */
50
+ function zeroizeKekCaches() {
51
+ for (const hook of _hooks) {
52
+ try {
53
+ hook();
54
+ }
55
+ catch {
56
+ /* swallow — zeroize is best-effort and must not throw */
57
+ }
58
+ }
59
+ }
60
+ /** @internal Test seam — drop all registered hooks. */
61
+ function __resetZeroizeRegistryForTest() {
62
+ _hooks.length = 0;
63
+ }