@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,311 @@
1
+ /**
2
+ * authHelper.ts — invocation contract for `OVERSKY_AUTH_HELPER`.
3
+ *
4
+ * The helper is a user-supplied executable that prints a fresh token to
5
+ * stdout. It exists so operators with rotating credentials (Vault, AWS
6
+ * Secrets Manager, internal SSO proxy) can plug in without OverSky needing
7
+ * to know how their secret store works. The contract mirrors Claude Code's
8
+ * `apiKeyHelper`.
9
+ *
10
+ * Wire protocol:
11
+ * - `OVERSKY_AUTH_HELPER` env points at an absolute, executable path.
12
+ * - We `spawn(path, [], { shell: false })` — never via shell, so no
13
+ * argument injection regardless of what's in the env value.
14
+ * - The helper writes EITHER a bare JWT to stdout, OR a JSON object
15
+ * `{ "token": "...", "expiresAt"?: <ms epoch> }`.
16
+ * - Stderr is captured for diagnostic logging only — never returned as
17
+ * token material.
18
+ * - Stdout cap: 64 KiB (`MAX_OUTPUT_BYTES`). Real JWTs are < 4 KiB.
19
+ * - Default timeout: 10 s (`OVERSKY_AUTH_HELPER_TIMEOUT_MS` overrides).
20
+ *
21
+ * Safety invariants:
22
+ * - Relative paths rejected — explicit absolute path required.
23
+ * - Non-executable file rejected (any of u/g/o exec bits must be set).
24
+ * - Non-zero exit, timeout, oversized output, malformed JSON → null.
25
+ * - On any failure the resolver chain falls through to the next slot;
26
+ * we never throw from a helper failure.
27
+ * - L6.3 (cold-cache failure sentinel) lives one layer up in
28
+ * `credentialResolver.ts` — when the SPAWN ATTEMPT itself fails on a
29
+ * cold cache the resolver caches a typed `{kind:'failed'}` sentinel
30
+ * to prevent silent fall-through to OAuth.
31
+ *
32
+ * Kill switch: `OVERSKY_DISABLE_AUTH_HELPER=1` short-circuits to null.
33
+ */
34
+ import { spawn } from 'node:child_process';
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { getAuthLogger } from './runtime.js';
38
+ const MAX_OUTPUT_BYTES = 64 * 1024;
39
+ const DEFAULT_TIMEOUT_MS = 10_000;
40
+ const DEFAULT_TTL_MS = 5 * 60_000;
41
+ /**
42
+ * L6.3 — failure outcomes that should TRIGGER the cold-cache sentinel.
43
+ * Validation failures (`disabled`, `invalid-path`, `not-executable`) are
44
+ * deliberately excluded: those mean "helper isn't really wired up" and
45
+ * the resolver chain should fall through to the next slot, not fail
46
+ * closed.
47
+ *
48
+ * Exported as a `const` set so the resolver can do
49
+ * `SENTINEL_TRIGGERING_FAILURES.has(reason)` without re-encoding the
50
+ * policy in two places.
51
+ */
52
+ export const SENTINEL_TRIGGERING_FAILURES = new Set([
53
+ 'spawn-error',
54
+ 'timeout',
55
+ 'non-zero-exit',
56
+ 'oversized',
57
+ 'empty-output',
58
+ 'parse-error',
59
+ ]);
60
+ /**
61
+ * Diagnostic outcome of the LAST `invokeAuthHelper()` call. The resolver
62
+ * reads this immediately after the call to decide whether to cache a
63
+ * cold-cache failure sentinel.
64
+ *
65
+ * Module-level (not part of the function return) because changing
66
+ * `invokeAuthHelper`'s return type would ripple through every existing
67
+ * caller and test. Reading it BEFORE another invocation is racy in
68
+ * theory but safe in practice — the resolver awaits the same promise it
69
+ * read this from.
70
+ */
71
+ let _lastFailureReason = null;
72
+ export function getLastAuthHelperFailureReason() {
73
+ return _lastFailureReason;
74
+ }
75
+ /** @internal test-only — clear the failure-reason latch between cases. */
76
+ export function __resetAuthHelperFailureReasonForTest() {
77
+ _lastFailureReason = null;
78
+ }
79
+ /**
80
+ * Invoke the helper script and return its parsed token, or null on any
81
+ * failure. Never throws.
82
+ */
83
+ export async function invokeAuthHelper(scriptPath, options = {}) {
84
+ const log = getAuthLogger();
85
+ // Reset on entry so a successful call clears any prior failure latch.
86
+ _lastFailureReason = null;
87
+ if (process.env.OVERSKY_DISABLE_AUTH_HELPER === '1') {
88
+ log.debug?.('[authHelper] OVERSKY_DISABLE_AUTH_HELPER=1 — skipping helper');
89
+ _lastFailureReason = 'disabled';
90
+ return null;
91
+ }
92
+ if (!scriptPath || !path.isAbsolute(scriptPath)) {
93
+ log.error(`[authHelper] OVERSKY_AUTH_HELPER must be an absolute path; got "${scriptPath}"`);
94
+ _lastFailureReason = 'invalid-path';
95
+ return null;
96
+ }
97
+ let stat;
98
+ try {
99
+ stat = fs.statSync(scriptPath);
100
+ }
101
+ catch (err) {
102
+ log.error(`[authHelper] OVERSKY_AUTH_HELPER not found at "${scriptPath}": ${formatErr(err)}`);
103
+ _lastFailureReason = 'invalid-path';
104
+ return null;
105
+ }
106
+ if (!stat.isFile()) {
107
+ log.error(`[authHelper] OVERSKY_AUTH_HELPER "${scriptPath}" is not a regular file`);
108
+ _lastFailureReason = 'invalid-path';
109
+ return null;
110
+ }
111
+ if ((stat.mode & 0o111) === 0) {
112
+ log.error(`[authHelper] OVERSKY_AUTH_HELPER "${scriptPath}" is not executable`);
113
+ _lastFailureReason = 'not-executable';
114
+ return null;
115
+ }
116
+ const timeoutMs = options.timeoutMs ?? parseTimeoutMs();
117
+ return new Promise((resolve) => {
118
+ const stdoutChunks = [];
119
+ const stderrChunks = [];
120
+ let stdoutBytes = 0;
121
+ let stderrBytes = 0;
122
+ let truncated = false;
123
+ let killedForTimeout = false;
124
+ let resolved = false;
125
+ const settle = (result) => {
126
+ if (resolved)
127
+ return;
128
+ resolved = true;
129
+ clearTimeout(timer);
130
+ resolve(result);
131
+ };
132
+ let child;
133
+ try {
134
+ child = spawn(scriptPath, [], {
135
+ shell: false,
136
+ // detached: true makes the child its own process-group leader so
137
+ // we can SIGKILL the whole group on timeout. Without this, killing
138
+ // the script's shell leaves grandchildren (sleep, curl, vault) as
139
+ // orphans holding stdio open — `close` would never fire and the
140
+ // daemon would hang past the timeout window.
141
+ detached: true,
142
+ stdio: ['ignore', 'pipe', 'pipe'],
143
+ });
144
+ }
145
+ catch (err) {
146
+ log.error(`[authHelper] spawn failed: ${formatErr(err)}`);
147
+ _lastFailureReason = 'spawn-error';
148
+ settle(null);
149
+ return;
150
+ }
151
+ const timer = setTimeout(() => {
152
+ killedForTimeout = true;
153
+ log.warn(`[authHelper] helper timed out after ${timeoutMs}ms — killing`);
154
+ // Kill the entire process group so subprocess trees (a helper that
155
+ // shells out to vault/aws/curl) don't outlive the timeout. The
156
+ // negative pid form targets the process group; falls back to a
157
+ // single-process kill on platforms or shapes where that fails.
158
+ try {
159
+ if (typeof child.pid === 'number') {
160
+ process.kill(-child.pid, 'SIGKILL');
161
+ }
162
+ else {
163
+ child.kill('SIGKILL');
164
+ }
165
+ }
166
+ catch {
167
+ try {
168
+ child.kill('SIGKILL');
169
+ }
170
+ catch {
171
+ /* ignore */
172
+ }
173
+ }
174
+ // Tear down stdio so any lingering pipe holders don't keep us hung.
175
+ try {
176
+ child.stdout?.destroy();
177
+ }
178
+ catch {
179
+ /* ignore */
180
+ }
181
+ try {
182
+ child.stderr?.destroy();
183
+ }
184
+ catch {
185
+ /* ignore */
186
+ }
187
+ _lastFailureReason = 'timeout';
188
+ // Settle immediately — do not wait for `close`. Orphan grandchildren
189
+ // may keep the close event pending indefinitely; the resolver chain
190
+ // must move on within the configured timeout.
191
+ settle(null);
192
+ }, timeoutMs);
193
+ if (typeof timer.unref === 'function')
194
+ timer.unref();
195
+ child.stdout.on('data', (chunk) => {
196
+ stdoutBytes += chunk.length;
197
+ if (stdoutBytes > MAX_OUTPUT_BYTES) {
198
+ truncated = true;
199
+ try {
200
+ child.kill('SIGKILL');
201
+ }
202
+ catch {
203
+ /* ignore */
204
+ }
205
+ return;
206
+ }
207
+ stdoutChunks.push(chunk);
208
+ });
209
+ child.stderr.on('data', (chunk) => {
210
+ // Defensive cap on stderr too (a chatty helper shouldn't OOM the daemon).
211
+ if (stderrBytes < MAX_OUTPUT_BYTES) {
212
+ stderrChunks.push(chunk);
213
+ stderrBytes += chunk.length;
214
+ }
215
+ });
216
+ child.on('error', (err) => {
217
+ log.error(`[authHelper] runtime error: ${formatErr(err)}`);
218
+ _lastFailureReason = 'spawn-error';
219
+ settle(null);
220
+ });
221
+ child.on('close', (code) => {
222
+ if (truncated) {
223
+ log.error(`[authHelper] stdout exceeded ${MAX_OUTPUT_BYTES} bytes — refusing to parse`);
224
+ _lastFailureReason = 'oversized';
225
+ settle(null);
226
+ return;
227
+ }
228
+ if (killedForTimeout) {
229
+ // _lastFailureReason already set to 'timeout' in the timer.
230
+ settle(null);
231
+ return;
232
+ }
233
+ const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim();
234
+ if (stderr)
235
+ log.debug?.(`[authHelper] stderr: ${stderr}`);
236
+ if (code !== 0) {
237
+ log.error(`[authHelper] helper exited with code ${code}`);
238
+ _lastFailureReason = 'non-zero-exit';
239
+ settle(null);
240
+ return;
241
+ }
242
+ const stdout = Buffer.concat(stdoutChunks).toString('utf-8').trim();
243
+ if (!stdout) {
244
+ log.error('[authHelper] helper produced empty stdout');
245
+ _lastFailureReason = 'empty-output';
246
+ settle(null);
247
+ return;
248
+ }
249
+ const parsed = parseHelperOutput(stdout, log);
250
+ if (parsed === null) {
251
+ _lastFailureReason = 'parse-error';
252
+ }
253
+ settle(parsed);
254
+ });
255
+ });
256
+ }
257
+ function parseHelperOutput(stdout, log) {
258
+ // JSON path first; fall back to bare-string token.
259
+ const trimmed = stdout.trim();
260
+ const looksLikeJson = trimmed.startsWith('{') || trimmed.startsWith('"');
261
+ if (looksLikeJson) {
262
+ try {
263
+ const obj = JSON.parse(trimmed);
264
+ if (typeof obj === 'string') {
265
+ return obj.length > 0 ? { token: obj } : null;
266
+ }
267
+ if (obj && typeof obj === 'object') {
268
+ const rec = obj;
269
+ const token = typeof rec.token === 'string' ? rec.token : null;
270
+ if (!token || token.length === 0) {
271
+ log.error('[authHelper] JSON output missing "token" field');
272
+ return null;
273
+ }
274
+ const expiresAt = typeof rec.expiresAt === 'number' && Number.isFinite(rec.expiresAt)
275
+ ? rec.expiresAt
276
+ : undefined;
277
+ return expiresAt !== undefined ? { token, expiresAt } : { token };
278
+ }
279
+ log.error('[authHelper] JSON output was not an object or string');
280
+ return null;
281
+ }
282
+ catch (err) {
283
+ log.error(`[authHelper] JSON parse failed: ${formatErr(err)}`);
284
+ return null;
285
+ }
286
+ }
287
+ // Bare token — single line, no JSON. Reject if it contains whitespace
288
+ // beyond surrounding trim (defensive — helper might have leaked logs).
289
+ if (/\s/.test(trimmed)) {
290
+ log.error('[authHelper] bare-token output contains whitespace — refusing (use JSON if helper logs to stdout)');
291
+ return null;
292
+ }
293
+ return { token: trimmed };
294
+ }
295
+ function parseTimeoutMs() {
296
+ const raw = process.env.OVERSKY_AUTH_HELPER_TIMEOUT_MS;
297
+ if (!raw)
298
+ return DEFAULT_TIMEOUT_MS;
299
+ const n = Number.parseInt(raw, 10);
300
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS;
301
+ }
302
+ export function getDefaultHelperTtlMs() {
303
+ const raw = process.env.OVERSKY_AUTH_HELPER_TTL_MS;
304
+ if (!raw)
305
+ return DEFAULT_TTL_MS;
306
+ const n = Number.parseInt(raw, 10);
307
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_TTL_MS;
308
+ }
309
+ function formatErr(err) {
310
+ return err instanceof Error ? err.message : String(err);
311
+ }
@@ -0,0 +1,85 @@
1
+ /** Serialized magic prefix. Bumped only on a breaking format change. */
2
+ export declare const ENVELOPE_MAGIC = "osk-env1";
3
+ /** Key length in bytes (256 bits). */
4
+ export declare const KEY_LEN = 32;
5
+ /**
6
+ * In-memory representation of a sealed value. Carries a version byte so a
7
+ * future `osk-env2` migration can dual-read both formats from disk.
8
+ */
9
+ export interface SealedBlob {
10
+ readonly v: 1;
11
+ readonly nonce: Buffer;
12
+ readonly ct: Buffer;
13
+ readonly tag: Buffer;
14
+ }
15
+ /**
16
+ * Generate a fresh 256-bit Data Encryption Key. Uses Node's crypto-random
17
+ * source (libsodium-grade on macOS/Linux, BCryptGenRandom on Windows).
18
+ */
19
+ export declare function generateDek(): Buffer;
20
+ /**
21
+ * Build the canonical AAD for binding a sealed credential to a specific
22
+ * profile + envelope version. Including profile in the AAD prevents an
23
+ * attacker who copies a sealed blob from profile `dev` into profile
24
+ * `prod`'s slot from getting a successful decrypt — the AAD won't match.
25
+ *
26
+ * Stable string format so a re-derivation in a future process produces
27
+ * the exact same AAD bytes for the same profile.
28
+ */
29
+ export declare function buildCredentialAad(profile: string, version?: number): Buffer;
30
+ /**
31
+ * Low-level AEAD seal. The shared primitive used by both DEK-wrapping and
32
+ * credential-encryption — the only difference between those operations is
33
+ * the semantic role of the key, not the cipher. Caller chooses the AAD.
34
+ */
35
+ export declare function sealBuffer(plaintext: Buffer, key: Buffer, aad?: Buffer): SealedBlob;
36
+ /**
37
+ * Low-level AEAD open. Throws on tag mismatch (wrong key, AAD mismatch,
38
+ * truncated/corrupted ciphertext) — never silently returns garbage.
39
+ */
40
+ export declare function openBuffer(sealed: SealedBlob, key: Buffer, aad?: Buffer): Buffer;
41
+ /**
42
+ * Wrap a 32-byte DEK under a KEK. The wrapped DEK is what gets persisted
43
+ * to disk — the KEK itself never leaves its platform store.
44
+ */
45
+ export declare function wrap(dek: Buffer, kek: Buffer, aad?: Buffer): SealedBlob;
46
+ /**
47
+ * Unwrap a previously-wrapped DEK using the same KEK. AAD must match the
48
+ * value used at wrap time or the GCM tag check fails.
49
+ */
50
+ export declare function unwrap(wrapped: SealedBlob, kek: Buffer, aad?: Buffer): Buffer;
51
+ /**
52
+ * Encrypt a credential (string or Buffer) under a DEK. Strings are
53
+ * encoded as UTF-8 before sealing; the open path returns a Buffer so the
54
+ * caller decides how to interpret it.
55
+ */
56
+ export declare function encrypt(plaintext: Buffer | string, dek: Buffer, aad?: Buffer): SealedBlob;
57
+ /**
58
+ * Decrypt a credential previously sealed with `encrypt`. Returns the raw
59
+ * plaintext bytes; UTF-8-decode at the call site if the original was a
60
+ * string.
61
+ */
62
+ export declare function decrypt(sealed: SealedBlob, dek: Buffer, aad?: Buffer): Buffer;
63
+ /**
64
+ * Serialize a SealedBlob to a single text token. Stable across processes
65
+ * and platforms — same blob serializes to the same string everywhere.
66
+ */
67
+ export declare function serialize(sealed: SealedBlob): string;
68
+ /**
69
+ * Parse a serialized envelope string. Returns null on any malformed
70
+ * input — callers should treat null as "this is not an envelope" and
71
+ * fall through to the legacy plaintext path.
72
+ *
73
+ * Deliberately tolerant: garbage in returns null, never throws. Throws
74
+ * are reserved for the cipher path so a wrong key surfaces as a real
75
+ * authentication failure instead of being silently swallowed.
76
+ */
77
+ export declare function deserialize(s: string): SealedBlob | null;
78
+ /**
79
+ * Cheap structural check — does this string LOOK like a serialized
80
+ * envelope? Used by dual-read paths to decide whether to attempt a
81
+ * decrypt or to treat the value as legacy plaintext. A true return does
82
+ * NOT guarantee the contents are valid; the actual decrypt is what
83
+ * authenticates the value.
84
+ */
85
+ export declare function isEnvelopeString(s: unknown): s is string;
@@ -0,0 +1,213 @@
1
+ /**
2
+ * credentialEnvelope.ts — at-rest envelope encryption primitives (L12 Layer 0).
3
+ *
4
+ * NAMING NOTE — this module is the "credential at-rest envelope." It is
5
+ * structurally distinct from `daemon/src/envelope-encryption.ts`, which
6
+ * encrypts wire-protocol session envelopes with a server-known DEK. The
7
+ * two should never share a key, a serialized format, or a rotation
8
+ * policy. We deliberately do NOT use the bare word "envelope" in this
9
+ * module's filename to avoid that confusion; future readers grepping for
10
+ * "envelope" must hit a name that immediately disambiguates which layer
11
+ * they are looking at.
12
+ *
13
+ * Threat model — what this module protects:
14
+ * - Cold copy of `~/Library/Keychains/login.keychain-db` taken from a
15
+ * sleeping laptop and brute-forced offline.
16
+ * - Backup snapshot of `~/.skrr/...` in Time Machine / Backblaze /
17
+ * Restic that ends up on a less-trusted disk.
18
+ * - Same-UID malware that reads the on-disk JSON file backend without
19
+ * standing up an OS prompt.
20
+ *
21
+ * What it does NOT protect:
22
+ * - Privileged on-host malware (root) reading the daemon's process memory.
23
+ * - Server-side compromise of refresh tokens that the server already
24
+ * holds by design.
25
+ *
26
+ * Hierarchy:
27
+ * plaintext credential
28
+ * ─ AES-256-GCM(DEK, nonce, AAD) ─▶ sealed credential blob
29
+ * plaintext DEK (32 bytes, generated locally)
30
+ * ─ AES-256-GCM(KEK, nonce, AAD) ─▶ wrapped DEK blob
31
+ * KEK (platform-native; Secure Enclave / DPAPI / libsecret-derived)
32
+ * never appears in JS memory in unwrappable form.
33
+ *
34
+ * This module owns ONLY the AES-256-GCM primitive plus the serialized
35
+ * format. Platform KEK acquisition lives in `./kek/`. DEK lifecycle
36
+ * (generation, rotation, persistence) lives in the daemon-side bridge.
37
+ */
38
+ import crypto from 'node:crypto';
39
+ import { aeadSeal, aeadOpen, AEAD_KEY_LEN, AEAD_NONCE_LEN, AEAD_TAG_LEN } from './aead.js';
40
+ /** Serialized magic prefix. Bumped only on a breaking format change. */
41
+ export const ENVELOPE_MAGIC = 'osk-env1';
42
+ /** Key length in bytes (256 bits). */
43
+ export const KEY_LEN = AEAD_KEY_LEN;
44
+ /** Nonce length in bytes (96 bits — GCM standard). */
45
+ const NONCE_LEN = AEAD_NONCE_LEN;
46
+ /** GCM authentication tag length in bytes (128 bits). */
47
+ const TAG_LEN = AEAD_TAG_LEN;
48
+ /**
49
+ * Generate a fresh 256-bit Data Encryption Key. Uses Node's crypto-random
50
+ * source (libsodium-grade on macOS/Linux, BCryptGenRandom on Windows).
51
+ */
52
+ export function generateDek() {
53
+ return crypto.randomBytes(KEY_LEN);
54
+ }
55
+ /**
56
+ * Build the canonical AAD for binding a sealed credential to a specific
57
+ * profile + envelope version. Including profile in the AAD prevents an
58
+ * attacker who copies a sealed blob from profile `dev` into profile
59
+ * `prod`'s slot from getting a successful decrypt — the AAD won't match.
60
+ *
61
+ * Stable string format so a re-derivation in a future process produces
62
+ * the exact same AAD bytes for the same profile.
63
+ */
64
+ export function buildCredentialAad(profile, version = 1) {
65
+ if (typeof profile !== 'string' || profile.length === 0) {
66
+ throw new Error('buildCredentialAad: profile must be a non-empty string');
67
+ }
68
+ if (!Number.isInteger(version) || version < 1) {
69
+ throw new Error('buildCredentialAad: version must be a positive integer');
70
+ }
71
+ return Buffer.from(`oversky:cred:v${version}:profile=${profile}`, 'utf-8');
72
+ }
73
+ /**
74
+ * Low-level AEAD seal. The shared primitive used by both DEK-wrapping and
75
+ * credential-encryption — the only difference between those operations is
76
+ * the semantic role of the key, not the cipher. Caller chooses the AAD.
77
+ */
78
+ export function sealBuffer(plaintext, key, aad) {
79
+ if (!Buffer.isBuffer(plaintext)) {
80
+ throw new TypeError('sealBuffer: plaintext must be a Buffer');
81
+ }
82
+ if (!Buffer.isBuffer(key) || key.length !== KEY_LEN) {
83
+ throw new TypeError(`sealBuffer: key must be a ${KEY_LEN}-byte Buffer`);
84
+ }
85
+ const { nonce, ct, tag } = aeadSeal(plaintext, key, aad);
86
+ return { v: 1, nonce, ct, tag };
87
+ }
88
+ /**
89
+ * Low-level AEAD open. Throws on tag mismatch (wrong key, AAD mismatch,
90
+ * truncated/corrupted ciphertext) — never silently returns garbage.
91
+ */
92
+ export function openBuffer(sealed, key, aad) {
93
+ if (!sealed || sealed.v !== 1) {
94
+ throw new Error('openBuffer: unsupported envelope version');
95
+ }
96
+ if (!Buffer.isBuffer(key) || key.length !== KEY_LEN) {
97
+ throw new TypeError(`openBuffer: key must be a ${KEY_LEN}-byte Buffer`);
98
+ }
99
+ if (!Buffer.isBuffer(sealed.nonce) || sealed.nonce.length !== NONCE_LEN) {
100
+ throw new Error('openBuffer: invalid nonce length');
101
+ }
102
+ if (!Buffer.isBuffer(sealed.tag) || sealed.tag.length !== TAG_LEN) {
103
+ throw new Error('openBuffer: invalid auth tag length');
104
+ }
105
+ if (!Buffer.isBuffer(sealed.ct)) {
106
+ throw new Error('openBuffer: invalid ciphertext');
107
+ }
108
+ return aeadOpen(sealed, key, aad);
109
+ }
110
+ /**
111
+ * Wrap a 32-byte DEK under a KEK. The wrapped DEK is what gets persisted
112
+ * to disk — the KEK itself never leaves its platform store.
113
+ */
114
+ export function wrap(dek, kek, aad) {
115
+ if (!Buffer.isBuffer(dek) || dek.length !== KEY_LEN) {
116
+ throw new TypeError(`wrap: dek must be a ${KEY_LEN}-byte Buffer`);
117
+ }
118
+ return sealBuffer(dek, kek, aad);
119
+ }
120
+ /**
121
+ * Unwrap a previously-wrapped DEK using the same KEK. AAD must match the
122
+ * value used at wrap time or the GCM tag check fails.
123
+ */
124
+ export function unwrap(wrapped, kek, aad) {
125
+ const out = openBuffer(wrapped, kek, aad);
126
+ if (out.length !== KEY_LEN) {
127
+ throw new Error(`unwrap: unwrapped DEK has wrong length (got ${out.length})`);
128
+ }
129
+ return out;
130
+ }
131
+ /**
132
+ * Encrypt a credential (string or Buffer) under a DEK. Strings are
133
+ * encoded as UTF-8 before sealing; the open path returns a Buffer so the
134
+ * caller decides how to interpret it.
135
+ */
136
+ export function encrypt(plaintext, dek, aad) {
137
+ const buf = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf-8') : plaintext;
138
+ return sealBuffer(buf, dek, aad);
139
+ }
140
+ /**
141
+ * Decrypt a credential previously sealed with `encrypt`. Returns the raw
142
+ * plaintext bytes; UTF-8-decode at the call site if the original was a
143
+ * string.
144
+ */
145
+ export function decrypt(sealed, dek, aad) {
146
+ return openBuffer(sealed, dek, aad);
147
+ }
148
+ // ---------------------------------------------------------------------------
149
+ // String serialization — keychain slots and JSON files store strings, so a
150
+ // SealedBlob has to round-trip through a single text token.
151
+ //
152
+ // Format: osk-env1:<b64u(nonce)>:<b64u(ct)>:<b64u(tag)>
153
+ // Length: ~88 chars for a typical 100-byte plaintext (well under the
154
+ // keychain's per-slot limit).
155
+ // ---------------------------------------------------------------------------
156
+ /** URL-safe base64 (no padding) — keychain-friendly, no `=` to confuse parsers. */
157
+ function b64u(buf) {
158
+ return buf.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
159
+ }
160
+ function b64uDecode(s) {
161
+ const padded = s.replace(/-/g, '+').replace(/_/g, '/');
162
+ const pad = padded.length % 4;
163
+ return Buffer.from(pad === 0 ? padded : padded + '='.repeat(4 - pad), 'base64');
164
+ }
165
+ /**
166
+ * Serialize a SealedBlob to a single text token. Stable across processes
167
+ * and platforms — same blob serializes to the same string everywhere.
168
+ */
169
+ export function serialize(sealed) {
170
+ if (!sealed || sealed.v !== 1) {
171
+ throw new Error('serialize: unsupported envelope version');
172
+ }
173
+ return `${ENVELOPE_MAGIC}:${b64u(sealed.nonce)}:${b64u(sealed.ct)}:${b64u(sealed.tag)}`;
174
+ }
175
+ /**
176
+ * Parse a serialized envelope string. Returns null on any malformed
177
+ * input — callers should treat null as "this is not an envelope" and
178
+ * fall through to the legacy plaintext path.
179
+ *
180
+ * Deliberately tolerant: garbage in returns null, never throws. Throws
181
+ * are reserved for the cipher path so a wrong key surfaces as a real
182
+ * authentication failure instead of being silently swallowed.
183
+ */
184
+ export function deserialize(s) {
185
+ if (typeof s !== 'string' || s.length === 0)
186
+ return null;
187
+ const parts = s.split(':');
188
+ if (parts.length !== 4)
189
+ return null;
190
+ if (parts[0] !== ENVELOPE_MAGIC)
191
+ return null;
192
+ try {
193
+ const nonce = b64uDecode(parts[1]);
194
+ const ct = b64uDecode(parts[2]);
195
+ const tag = b64uDecode(parts[3]);
196
+ if (nonce.length !== NONCE_LEN || tag.length !== TAG_LEN)
197
+ return null;
198
+ return { v: 1, nonce, ct, tag };
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ }
204
+ /**
205
+ * Cheap structural check — does this string LOOK like a serialized
206
+ * envelope? Used by dual-read paths to decide whether to attempt a
207
+ * decrypt or to treat the value as legacy plaintext. A true return does
208
+ * NOT guarantee the contents are valid; the actual decrypt is what
209
+ * authenticates the value.
210
+ */
211
+ export function isEnvelopeString(s) {
212
+ return typeof s === 'string' && s.startsWith(`${ENVELOPE_MAGIC}:`);
213
+ }