@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,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,39 @@
|
|
|
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
|
+
* Sentinel error class used by KEK implementations to signal
|
|
25
|
+
* unrecoverable native failures (e.g. Secure Enclave key invalidated by
|
|
26
|
+
* a Touch ID change). The daemon's integration treats these as
|
|
27
|
+
* `available=false` going forward and re-probes on the next startup.
|
|
28
|
+
*
|
|
29
|
+
* Distinct from generic `Error` so the integration's catch can decide
|
|
30
|
+
* whether to fall back permanently or just for this request.
|
|
31
|
+
*/
|
|
32
|
+
export class KekUnavailableError extends Error {
|
|
33
|
+
id;
|
|
34
|
+
constructor(id, message, opts) {
|
|
35
|
+
super(message, opts);
|
|
36
|
+
this.name = 'KekUnavailableError';
|
|
37
|
+
this.id = id;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -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,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kek/windows.ts — Windows DPAPI KEK strategy.
|
|
3
|
+
*
|
|
4
|
+
* ARCHITECTURAL NOTE — DPAPI IS the wrap, not a key store:
|
|
5
|
+
*
|
|
6
|
+
* Unlike the macOS path (which keeps a 32-byte KEK in the Keychain and
|
|
7
|
+
* then AES-256-GCM-wraps the DEK with that KEK), Windows DPAPI is
|
|
8
|
+
* structurally different. `ProtectedData.Protect(plaintext, entropy, scope)`
|
|
9
|
+
* returns a self-contained ciphertext blob that you persist yourself.
|
|
10
|
+
* There is no named persistent credential to load; the "KEK" is implicit
|
|
11
|
+
* in the user's Windows profile master key, derived from login credentials.
|
|
12
|
+
*
|
|
13
|
+
* As a result:
|
|
14
|
+
* - `wrap(dek, aad)` calls DPAPI directly with `optionalEntropy = aad`.
|
|
15
|
+
* - `unwrap(wrapped, aad)` calls DPAPI with the same entropy.
|
|
16
|
+
* - The bytes returned to `credEnvelope.ts` ARE the DPAPI ciphertext;
|
|
17
|
+
* there is no separate AES-GCM layer on top.
|
|
18
|
+
* - Do NOT attempt to unify this with the macOS `credentialEnvelope.ts`
|
|
19
|
+
* AES-GCM layer. The primitives are different by design.
|
|
20
|
+
*
|
|
21
|
+
* Security model (CurrentUser scope — mandatory):
|
|
22
|
+
* - DPAPI binds the ciphertext to the Windows user identity via the
|
|
23
|
+
* user's login-derived master key. A different Windows account on the
|
|
24
|
+
* same machine CANNOT decrypt the blob.
|
|
25
|
+
* - `DataProtectionScope.CurrentUser` is always used. `LocalMachine`
|
|
26
|
+
* scope is explicitly NOT used: it would allow any account on the box
|
|
27
|
+
* to decrypt — violating cross-user isolation.
|
|
28
|
+
* - `optionalEntropy` = the `aad` buffer acts as AAD-equivalent:
|
|
29
|
+
* must match exactly at unwrap time.
|
|
30
|
+
*
|
|
31
|
+
* Backend — PowerShell shellout:
|
|
32
|
+
* PowerShell is available on every Windows installation since Win 7+.
|
|
33
|
+
* No extra native dependencies are required, matching the project
|
|
34
|
+
* precedent in `daemon/src/keychain.ts`. A NAPI binding can replace
|
|
35
|
+
* this in a future Phase D for enterprise hardened environments
|
|
36
|
+
* (PowerShell Constrained Language Mode blocks the DPAPI assembly load;
|
|
37
|
+
* see `available()` — probe will return false in those environments).
|
|
38
|
+
*
|
|
39
|
+
* Argument size limit:
|
|
40
|
+
* PowerShell `-Command` is bounded at roughly 32 KB. A 32-byte DEK and
|
|
41
|
+
* small AAD labels are well within that limit. Document and enforce at
|
|
42
|
+
* the call site if callers begin passing large AAD in a future revision.
|
|
43
|
+
*
|
|
44
|
+
* Edge cases (documented; not tested in this PR):
|
|
45
|
+
* - CRYPTPROTECT master-key roll (user profile reset): `Unprotect` throws
|
|
46
|
+
* `CryptographicException` → surfaces as plain `Error` → upstream
|
|
47
|
+
* `credEnvelope.ts` treats as `kek_rotated` → forces re-enrollment.
|
|
48
|
+
* - Roaming profiles: `CurrentUser` scope is user-bound + machine-local,
|
|
49
|
+
* so a roamed login on a new box re-runs first-time enrollment naturally.
|
|
50
|
+
* - PowerShell Constrained Language Mode: probe will return false since
|
|
51
|
+
* `[System.Security.Cryptography.ProtectedData]` access is blocked.
|
|
52
|
+
* `available()` correctly reflects this as unavailable.
|
|
53
|
+
*/
|
|
54
|
+
import { execFile } from 'node:child_process';
|
|
55
|
+
import { promisify } from 'node:util';
|
|
56
|
+
import { KekUnavailableError } from './types.js';
|
|
57
|
+
const KIND = 'windows-dpapi-powershell-shellout';
|
|
58
|
+
const execFileAsync = promisify(execFile);
|
|
59
|
+
let _execFileForTest = null;
|
|
60
|
+
export function __setExecFileForTest(fn) {
|
|
61
|
+
_execFileForTest = fn;
|
|
62
|
+
// Reset the probe cache so the next available() re-evaluates with the mock.
|
|
63
|
+
_powershellProbeCache = undefined;
|
|
64
|
+
}
|
|
65
|
+
/** Default execFile wrapper used when no test override is installed. */
|
|
66
|
+
const defaultExec = async (file, args, options) => {
|
|
67
|
+
const result = await execFileAsync(file, args, {
|
|
68
|
+
encoding: 'utf-8',
|
|
69
|
+
...(options ?? {}),
|
|
70
|
+
});
|
|
71
|
+
// Defensive coercion — when callers override the encoding, stdout may
|
|
72
|
+
// surface as Buffer/Uint8Array; the typeof guard keeps both shapes safe.
|
|
73
|
+
return {
|
|
74
|
+
stdout: typeof result.stdout === 'string'
|
|
75
|
+
? result.stdout
|
|
76
|
+
: Buffer.from(result.stdout).toString('utf-8'),
|
|
77
|
+
stderr: typeof result.stderr === 'string'
|
|
78
|
+
? result.stderr
|
|
79
|
+
: Buffer.from(result.stderr).toString('utf-8'),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
function exec(file, args) {
|
|
83
|
+
const impl = _execFileForTest ?? defaultExec;
|
|
84
|
+
return impl(file, args, { encoding: 'utf-8' });
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Probe — cached for the process lifetime to keep available() ~O(1).
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
/** `undefined` = not yet probed; `boolean` = cached probe outcome. */
|
|
90
|
+
let _powershellProbeCache;
|
|
91
|
+
/**
|
|
92
|
+
* Probe whether PowerShell and the DPAPI assembly are accessible.
|
|
93
|
+
*
|
|
94
|
+
* Checks that `[System.Security.Cryptography.ProtectedData]` resolves —
|
|
95
|
+
* a non-empty stdout means the type loaded and DPAPI is usable.
|
|
96
|
+
* Exit 0 with empty stdout, non-zero exit, or ENOENT → false.
|
|
97
|
+
*
|
|
98
|
+
* Enterprise environments with PowerShell Constrained Language Mode will
|
|
99
|
+
* fail here because the ProtectedData type is blocked in that mode.
|
|
100
|
+
*/
|
|
101
|
+
async function probePowerShell() {
|
|
102
|
+
if (process.platform !== 'win32')
|
|
103
|
+
return false;
|
|
104
|
+
if (_powershellProbeCache !== undefined)
|
|
105
|
+
return _powershellProbeCache;
|
|
106
|
+
try {
|
|
107
|
+
const { stdout } = await exec('powershell.exe', [
|
|
108
|
+
'-NoProfile',
|
|
109
|
+
'-NonInteractive',
|
|
110
|
+
'-Command',
|
|
111
|
+
'[System.Security.Cryptography.ProtectedData].FullName',
|
|
112
|
+
]);
|
|
113
|
+
_powershellProbeCache = stdout.trim().length > 0;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
_powershellProbeCache = false;
|
|
117
|
+
}
|
|
118
|
+
return _powershellProbeCache;
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// DPAPI shellout helpers.
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
/**
|
|
124
|
+
* Call `ProtectedData.Protect` via PowerShell.
|
|
125
|
+
*
|
|
126
|
+
* Both `plaintext` and `entropy` are passed as base64 strings inside the
|
|
127
|
+
* PowerShell command. Base64 chars (`A-Za-z0-9+/=`) are safe inside
|
|
128
|
+
* PowerShell single-quoted strings — no escaping needed.
|
|
129
|
+
*
|
|
130
|
+
* Scope: `CurrentUser` — mandatory for cross-user isolation.
|
|
131
|
+
*
|
|
132
|
+
* @param plaintext - The bytes to protect (the DEK).
|
|
133
|
+
* @param entropy - Optional entropy / AAD-equivalent. Empty Buffer → $null.
|
|
134
|
+
* @returns DPAPI ciphertext as a Buffer.
|
|
135
|
+
* @throws {KekUnavailableError} on any PowerShell or DPAPI failure.
|
|
136
|
+
*/
|
|
137
|
+
async function dpapiProtect(plaintext, entropy) {
|
|
138
|
+
const ptB64 = plaintext.toString('base64');
|
|
139
|
+
const enB64 = entropy.length > 0 ? entropy.toString('base64') : null;
|
|
140
|
+
const entropyExpr = enB64 !== null ? `$en = [Convert]::FromBase64String('${enB64}');` : '$en = $null;';
|
|
141
|
+
const command = [
|
|
142
|
+
`$pt = [Convert]::FromBase64String('${ptB64}');`,
|
|
143
|
+
entropyExpr,
|
|
144
|
+
'$ct = [System.Security.Cryptography.ProtectedData]::Protect($pt, $en, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);',
|
|
145
|
+
'[Convert]::ToBase64String($ct)',
|
|
146
|
+
].join(' ');
|
|
147
|
+
let stdout;
|
|
148
|
+
let stderr;
|
|
149
|
+
try {
|
|
150
|
+
({ stdout, stderr } = await exec('powershell.exe', [
|
|
151
|
+
'-NoProfile',
|
|
152
|
+
'-NonInteractive',
|
|
153
|
+
'-Command',
|
|
154
|
+
command,
|
|
155
|
+
]));
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
throw new KekUnavailableError(KIND, `DPAPI Protect failed: ${err.message}`, {
|
|
159
|
+
cause: err,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (stderr && stderr.includes('CryptographicException')) {
|
|
163
|
+
throw new KekUnavailableError(KIND, `DPAPI Protect CryptographicException: ${stderr.trim()}`);
|
|
164
|
+
}
|
|
165
|
+
const trimmed = stdout.trim();
|
|
166
|
+
if (trimmed.length === 0) {
|
|
167
|
+
throw new KekUnavailableError(KIND, 'DPAPI Protect returned empty output');
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return Buffer.from(trimmed, 'base64');
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
throw new KekUnavailableError(KIND, 'DPAPI Protect output is not valid base64', { cause: err });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Call `ProtectedData.Unprotect` via PowerShell.
|
|
178
|
+
*
|
|
179
|
+
* DPAPI failure (e.g. master-key roll after user-profile reset,
|
|
180
|
+
* `CryptographicException`) is surfaced as a plain `Error` (NOT
|
|
181
|
+
* `KekUnavailableError`) so that upstream `credEnvelope.ts` can map the
|
|
182
|
+
* failure to the `kek_rotated` path and force re-enrollment rather than
|
|
183
|
+
* permanently disabling the KEK.
|
|
184
|
+
*
|
|
185
|
+
* @param wrapped - DPAPI ciphertext produced by `dpapiProtect`.
|
|
186
|
+
* @param entropy - Must match the entropy used at protect time exactly.
|
|
187
|
+
* @returns Plaintext bytes (the DEK).
|
|
188
|
+
* @throws {Error} on DPAPI CryptographicException (mismatched key/entropy,
|
|
189
|
+
* corrupted blob, master-key roll). NOT KekUnavailableError — see above.
|
|
190
|
+
* @throws {KekUnavailableError} on PowerShell invocation failure unrelated
|
|
191
|
+
* to DPAPI crypto (binary missing, empty output, base64 decode error).
|
|
192
|
+
*/
|
|
193
|
+
async function dpapiUnprotect(wrapped, entropy) {
|
|
194
|
+
const wrappedB64 = wrapped.toString('base64');
|
|
195
|
+
const enB64 = entropy.length > 0 ? entropy.toString('base64') : null;
|
|
196
|
+
const entropyExpr = enB64 !== null ? `$en = [Convert]::FromBase64String('${enB64}');` : '$en = $null;';
|
|
197
|
+
const command = [
|
|
198
|
+
`$ct = [Convert]::FromBase64String('${wrappedB64}');`,
|
|
199
|
+
entropyExpr,
|
|
200
|
+
'$pt = [System.Security.Cryptography.ProtectedData]::Unprotect($ct, $en, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);',
|
|
201
|
+
'[Convert]::ToBase64String($pt)',
|
|
202
|
+
].join(' ');
|
|
203
|
+
let stdout;
|
|
204
|
+
let stderr;
|
|
205
|
+
try {
|
|
206
|
+
({ stdout, stderr } = await exec('powershell.exe', [
|
|
207
|
+
'-NoProfile',
|
|
208
|
+
'-NonInteractive',
|
|
209
|
+
'-Command',
|
|
210
|
+
command,
|
|
211
|
+
]));
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
// Non-zero exit from PowerShell — check for CryptographicException
|
|
215
|
+
// which signals a DPAPI failure (mismatched key, corrupted blob, etc.).
|
|
216
|
+
const msg = err.message ?? '';
|
|
217
|
+
const stderrStr = err.stderr ?? '';
|
|
218
|
+
if (msg.includes('CryptographicException') || stderrStr.includes('CryptographicException')) {
|
|
219
|
+
// Plain Error so upstream maps to kek_rotated, not kek_unavailable.
|
|
220
|
+
throw new Error(`DPAPI Unprotect CryptographicException: ${stderrStr || msg}`);
|
|
221
|
+
}
|
|
222
|
+
throw new KekUnavailableError(KIND, `DPAPI Unprotect failed: ${msg}`, { cause: err });
|
|
223
|
+
}
|
|
224
|
+
if (stderr && stderr.includes('CryptographicException')) {
|
|
225
|
+
// Surfaces on some PowerShell versions via stderr without non-zero exit.
|
|
226
|
+
throw new Error(`DPAPI Unprotect CryptographicException: ${stderr.trim()}`);
|
|
227
|
+
}
|
|
228
|
+
const trimmed = stdout.trim();
|
|
229
|
+
if (trimmed.length === 0) {
|
|
230
|
+
throw new KekUnavailableError(KIND, 'DPAPI Unprotect returned empty output');
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
return Buffer.from(trimmed, 'base64');
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
throw new KekUnavailableError(KIND, 'DPAPI Unprotect output is not valid base64', {
|
|
237
|
+
cause: err,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// WindowsKek — KekStrategy implementation.
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
/**
|
|
245
|
+
* @internal Test seam — no-op for Windows since DPAPI is stateless on
|
|
246
|
+
* the library side (no in-process master-blob to clear). Exists for API
|
|
247
|
+
* symmetry with `__resetKekCacheForTest` on macOS so test harness setup
|
|
248
|
+
* code can call the same pattern across platforms.
|
|
249
|
+
*/
|
|
250
|
+
export function __resetMasterBlobForTest() {
|
|
251
|
+
// DPAPI is self-contained per-blob; there is no in-process master-KEK
|
|
252
|
+
// cache to clear. This seam is intentionally a no-op.
|
|
253
|
+
_powershellProbeCache = undefined;
|
|
254
|
+
}
|
|
255
|
+
export class WindowsKek {
|
|
256
|
+
id = 'windows-dpapi';
|
|
257
|
+
/**
|
|
258
|
+
* Telemetry kind of the most recent successful wrap/unwrap. `null`
|
|
259
|
+
* before any call. Always `'windows-dpapi-powershell-shellout'` for
|
|
260
|
+
* this Tier-2 implementation.
|
|
261
|
+
*/
|
|
262
|
+
_lastKind = null;
|
|
263
|
+
get kind() {
|
|
264
|
+
return this._lastKind;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Returns true when PowerShell is on PATH and the DPAPI assembly loads.
|
|
268
|
+
*
|
|
269
|
+
* Result is cached for the process lifetime. On probe failure (ENOENT,
|
|
270
|
+
* empty stdout, PowerShell Constrained Language Mode blocking the type),
|
|
271
|
+
* returns false and the daemon falls through to the legacy plaintext
|
|
272
|
+
* backend.
|
|
273
|
+
*/
|
|
274
|
+
async available() {
|
|
275
|
+
return probePowerShell();
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Wrap `dek` using Windows DPAPI with `aad` as optional entropy.
|
|
279
|
+
*
|
|
280
|
+
* IMPORTANT: DPAPI IS the wrap — there is no separate AES-GCM layer.
|
|
281
|
+
* The returned bytes are the raw DPAPI ciphertext blob and should be
|
|
282
|
+
* persisted as-is (e.g. to a 0600 file under the profile config dir).
|
|
283
|
+
*
|
|
284
|
+
* The `aad` buffer acts as DPAPI's `optionalEntropy`. Must match
|
|
285
|
+
* exactly at unwrap time. If `aad` is omitted or empty, `$null`
|
|
286
|
+
* entropy is used — still bound to user identity, but without the
|
|
287
|
+
* extra entropy protection.
|
|
288
|
+
*
|
|
289
|
+
* @throws {KekUnavailableError} on non-win32 platform or PowerShell failure.
|
|
290
|
+
*/
|
|
291
|
+
async wrap(dek, aad) {
|
|
292
|
+
if (process.platform !== 'win32') {
|
|
293
|
+
throw new KekUnavailableError(this.id, 'Windows DPAPI KEK called on non-win32 platform');
|
|
294
|
+
}
|
|
295
|
+
if (!(await probePowerShell())) {
|
|
296
|
+
throw new KekUnavailableError(this.id, 'Windows DPAPI KEK unavailable: PowerShell or DPAPI assembly not accessible');
|
|
297
|
+
}
|
|
298
|
+
const aadBuf = aad ?? Buffer.alloc(0);
|
|
299
|
+
const out = await dpapiProtect(dek, aadBuf);
|
|
300
|
+
this._lastKind = KIND;
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Unwrap a previously-wrapped DEK using Windows DPAPI.
|
|
305
|
+
*
|
|
306
|
+
* The `aad` buffer must match exactly what was passed to `wrap()`.
|
|
307
|
+
* DPAPI CryptographicException (mismatched entropy, corrupted blob,
|
|
308
|
+
* master-key roll after user-profile reset) surfaces as a plain `Error`
|
|
309
|
+
* so that upstream `credEnvelope.ts` can map it to the `kek_rotated`
|
|
310
|
+
* path and force re-enrollment.
|
|
311
|
+
*
|
|
312
|
+
* @throws {Error} on DPAPI CryptographicException (kek_rotated path).
|
|
313
|
+
* @throws {KekUnavailableError} on non-win32 or PowerShell invocation failure.
|
|
314
|
+
*/
|
|
315
|
+
async unwrap(wrapped, aad) {
|
|
316
|
+
if (process.platform !== 'win32') {
|
|
317
|
+
throw new KekUnavailableError(this.id, 'Windows DPAPI KEK called on non-win32 platform');
|
|
318
|
+
}
|
|
319
|
+
if (!(await probePowerShell())) {
|
|
320
|
+
throw new KekUnavailableError(this.id, 'Windows DPAPI KEK unavailable: PowerShell or DPAPI assembly not accessible');
|
|
321
|
+
}
|
|
322
|
+
const aadBuf = aad ?? Buffer.alloc(0);
|
|
323
|
+
const out = await dpapiUnprotect(wrapped, aadBuf);
|
|
324
|
+
this._lastKind = KIND;
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
// DPAPI owns its master-key lifecycle; unprotect never creates application
|
|
328
|
+
// KEK material, so the normal unwrap is safe for the optimistic read path.
|
|
329
|
+
async unwrapExisting(wrapped, aad) {
|
|
330
|
+
return this.unwrap(wrapped, aad);
|
|
331
|
+
}
|
|
332
|
+
describe() {
|
|
333
|
+
if (process.platform !== 'win32') {
|
|
334
|
+
return 'Windows DPAPI KEK (not win32 — unavailable)';
|
|
335
|
+
}
|
|
336
|
+
if (_powershellProbeCache === true) {
|
|
337
|
+
return 'Windows DPAPI (CurrentUser) via PowerShell shellout';
|
|
338
|
+
}
|
|
339
|
+
if (_powershellProbeCache === false) {
|
|
340
|
+
return 'Windows DPAPI KEK unavailable (PowerShell or DPAPI assembly inaccessible)';
|
|
341
|
+
}
|
|
342
|
+
return 'Windows DPAPI KEK (probe pending — call available() to materialize)';
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -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,58 @@
|
|
|
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
|
+
const _hooks = [];
|
|
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 function registerZeroizeHook(fn) {
|
|
31
|
+
if (_hooks.indexOf(fn) === -1) {
|
|
32
|
+
_hooks.push(fn);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Fire every registered zeroize hook. Errors are swallowed — zeroization
|
|
37
|
+
* is best-effort; a failure to clear one platform's cache must not block
|
|
38
|
+
* the others, and there is no useful recovery path for the caller.
|
|
39
|
+
*
|
|
40
|
+
* Daemon callers should invoke this on:
|
|
41
|
+
* - SIGTERM / SIGINT (graceful shutdown)
|
|
42
|
+
* - KEK rotation (after the new wrap is materialized)
|
|
43
|
+
* - Logout (after at-rest credentials are wiped)
|
|
44
|
+
*/
|
|
45
|
+
export function zeroizeKekCaches() {
|
|
46
|
+
for (const hook of _hooks) {
|
|
47
|
+
try {
|
|
48
|
+
hook();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* swallow — zeroize is best-effort and must not throw */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** @internal Test seam — drop all registered hooks. */
|
|
56
|
+
export function __resetZeroizeRegistryForTest() {
|
|
57
|
+
_hooks.length = 0;
|
|
58
|
+
}
|