@raidiant/notifai 1.0.1 → 2.0.0
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/CHANGELOG.md +50 -0
- package/README.md +17 -7
- package/dist/client.d.ts +5 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +151 -54
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +5 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +54 -3
- package/dist/credentials.js +237 -18
- package/dist/credentials.js.map +1 -1
- package/dist/file-lock.js +15 -6
- package/dist/file-lock.js.map +1 -1
- package/dist/hook-adapter.d.ts +6 -2
- package/dist/hook-adapter.js +110 -29
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +15 -1
- package/dist/hooks.js +183 -12
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +50 -21
- package/dist/install-hooks.js +141 -57
- package/dist/install-hooks.js.map +1 -1
- package/dist/logging.d.ts +1 -1
- package/dist/logging.js +4 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +14 -3
- package/dist/main.js.map +1 -1
- package/dist/native-skills.js +5 -10
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -2
- package/dist/opencode-plugin.js +142 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/platform.d.ts +73 -0
- package/dist/platform.js +143 -0
- package/dist/platform.js.map +1 -0
- package/dist/ui/config-view.js +5 -1
- package/dist/ui/config-view.js.map +1 -1
- package/package.json +11 -10
- package/src/client.test.ts +53 -0
- package/src/client.ts +24 -0
- package/src/commands.test.ts +351 -30
- package/src/commands.ts +207 -49
- package/src/config.ts +11 -9
- package/src/credentials.test.ts +291 -0
- package/src/credentials.ts +274 -17
- package/src/file-lock.ts +34 -9
- package/src/hook-adapter.test.ts +136 -0
- package/src/hook-adapter.ts +120 -28
- package/src/hooks.test.ts +175 -1
- package/src/hooks.ts +241 -12
- package/src/install-hooks.test.ts +174 -33
- package/src/install-hooks.ts +186 -54
- package/src/logging.ts +4 -0
- package/src/main.ts +17 -2
- package/src/native-skills.ts +5 -10
- package/src/opencode-plugin.ts +148 -3
- package/src/platform.test.ts +152 -0
- package/src/platform.ts +173 -0
- package/src/skill.test.ts +32 -1
- package/src/ui/config-view.ts +5 -5
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
FileStore,
|
|
16
|
+
WindowsDpapiStore,
|
|
17
|
+
defaultCredentialStore,
|
|
18
|
+
windowsDpapiOperation,
|
|
19
|
+
type MachineCredential,
|
|
20
|
+
type ProcessRunner,
|
|
21
|
+
type RunCommandSpec,
|
|
22
|
+
} from './credentials.js'
|
|
23
|
+
|
|
24
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'notifai-cred-'))
|
|
25
|
+
afterAll(() => rmSync(tmp, { recursive: true, force: true }))
|
|
26
|
+
|
|
27
|
+
const SAMPLE: MachineCredential = {
|
|
28
|
+
machineId: 'mach_fixture',
|
|
29
|
+
secret: 'fixture-secret',
|
|
30
|
+
baseUrl: 'https://example.test',
|
|
31
|
+
machineName: 'fixture-machine',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OTHER: MachineCredential = {
|
|
35
|
+
machineId: 'mach_other',
|
|
36
|
+
secret: 'other-secret',
|
|
37
|
+
baseUrl: 'https://other.test',
|
|
38
|
+
machineName: 'other-machine',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sandbox(name: string): {
|
|
42
|
+
env: NodeJS.ProcessEnv
|
|
43
|
+
local: string
|
|
44
|
+
roaming: string
|
|
45
|
+
config: string
|
|
46
|
+
} {
|
|
47
|
+
const root = path.join(tmp, name)
|
|
48
|
+
const local = path.join(root, 'local')
|
|
49
|
+
const roaming = path.join(root, 'roaming')
|
|
50
|
+
const config = path.join(root, 'config')
|
|
51
|
+
mkdirSync(local, { recursive: true })
|
|
52
|
+
mkdirSync(roaming, { recursive: true })
|
|
53
|
+
mkdirSync(config, { recursive: true })
|
|
54
|
+
return {
|
|
55
|
+
local,
|
|
56
|
+
roaming,
|
|
57
|
+
config,
|
|
58
|
+
env: {
|
|
59
|
+
LOCALAPPDATA: local,
|
|
60
|
+
APPDATA: roaming,
|
|
61
|
+
XDG_CONFIG_HOME: config,
|
|
62
|
+
SystemRoot: 'C:\\Windows',
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeRaw(file: string, contents: string): void {
|
|
68
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
69
|
+
writeFileSync(file, contents)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mockDpapi(options: { failProtect?: boolean; failUnprotect?: boolean } = {}): {
|
|
73
|
+
runner: ProcessRunner
|
|
74
|
+
calls: RunCommandSpec[]
|
|
75
|
+
} {
|
|
76
|
+
const vault = new Map<string, string>()
|
|
77
|
+
const calls: RunCommandSpec[] = []
|
|
78
|
+
const runner: ProcessRunner = {
|
|
79
|
+
run(spec) {
|
|
80
|
+
calls.push({
|
|
81
|
+
command: spec.command,
|
|
82
|
+
args: spec.args,
|
|
83
|
+
input: spec.input,
|
|
84
|
+
...(spec.timeoutMs === undefined ? {} : { timeoutMs: spec.timeoutMs }),
|
|
85
|
+
})
|
|
86
|
+
const operation = windowsDpapiOperation(spec.args)
|
|
87
|
+
if (operation === 'protect') {
|
|
88
|
+
if (options.failProtect) return { status: 1, stdout: Buffer.alloc(0) }
|
|
89
|
+
const token = Buffer.from(`dpapi:${vault.size}`, 'utf8').toString('base64')
|
|
90
|
+
vault.set(token, spec.input.toString('utf8'))
|
|
91
|
+
return { status: 0, stdout: Buffer.from(token, 'utf8') }
|
|
92
|
+
}
|
|
93
|
+
if (operation === 'unprotect') {
|
|
94
|
+
if (options.failUnprotect) return { status: 1, stdout: Buffer.alloc(0) }
|
|
95
|
+
const token = spec.input.toString('utf8').trim()
|
|
96
|
+
const plain = vault.get(token)
|
|
97
|
+
if (plain === undefined) return { status: 1, stdout: Buffer.alloc(0) }
|
|
98
|
+
return { status: 0, stdout: Buffer.from(plain, 'utf8') }
|
|
99
|
+
}
|
|
100
|
+
return { status: 1, stdout: Buffer.alloc(0) }
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
return { runner, calls }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertNoSecretInArgs(calls: RunCommandSpec[]): void {
|
|
107
|
+
for (const call of calls) {
|
|
108
|
+
const argv = [call.command, ...call.args].join('\0')
|
|
109
|
+
expect(argv).not.toContain(SAMPLE.secret)
|
|
110
|
+
expect(argv).not.toContain(OTHER.secret)
|
|
111
|
+
expect(argv).not.toContain(SAMPLE.machineId)
|
|
112
|
+
expect(argv).not.toContain('machineId')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe('defaultCredentialStore', () => {
|
|
117
|
+
it('selects the DPAPI store on win32', () => {
|
|
118
|
+
const { env } = sandbox('default-win32')
|
|
119
|
+
const store = defaultCredentialStore(env, { platform: 'win32', run: mockDpapi().runner })
|
|
120
|
+
expect(store).toBeInstanceOf(WindowsDpapiStore)
|
|
121
|
+
expect(store.describe()).toMatch(/Windows DPAPI current-user/)
|
|
122
|
+
expect(store.describe()).toContain(path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi'))
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('keeps the explicit file escape hatch on win32 under APPDATA', () => {
|
|
126
|
+
const { env } = sandbox('default-file-win32')
|
|
127
|
+
const store = defaultCredentialStore(
|
|
128
|
+
{ ...env, NOTIFAI_CREDENTIALS: 'file', XDG_CONFIG_HOME: undefined },
|
|
129
|
+
{ platform: 'win32' },
|
|
130
|
+
)
|
|
131
|
+
expect(store).toBeInstanceOf(FileStore)
|
|
132
|
+
expect(store.describe()).toContain(path.join(env.APPDATA!, 'notifai', 'credentials.json'))
|
|
133
|
+
expect(store.describe()).toMatch(/plaintext file/)
|
|
134
|
+
expect(store.describe()).toMatch(/POSIX mode bits are not protection on NTFS/)
|
|
135
|
+
expect(store.describe()).not.toMatch(/0600/)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('preserves macOS Keychain when available', () => {
|
|
139
|
+
const store = defaultCredentialStore({}, { platform: 'darwin', keychainAvailable: true })
|
|
140
|
+
expect(store.describe()).toBe('macOS Keychain (io.notifai.cli)')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('falls back to the 0600 file store on linux', () => {
|
|
144
|
+
const { env } = sandbox('default-linux')
|
|
145
|
+
const store = defaultCredentialStore(env, { platform: 'linux' })
|
|
146
|
+
expect(store).toBeInstanceOf(FileStore)
|
|
147
|
+
expect(store.describe()).toMatch(/plaintext file/)
|
|
148
|
+
expect(store.describe()).toMatch(/mode 0600/)
|
|
149
|
+
expect(store.describe()).not.toMatch(/NTFS/)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('uses the file store on macOS when Keychain is unavailable', () => {
|
|
153
|
+
const { env } = sandbox('default-darwin-file')
|
|
154
|
+
const store = defaultCredentialStore(env, { platform: 'darwin', keychainAvailable: false })
|
|
155
|
+
expect(store).toBeInstanceOf(FileStore)
|
|
156
|
+
expect(store.describe()).toMatch(/mode 0600/)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('FileStore', () => {
|
|
161
|
+
it('saves, loads, overwrites, and clears atomically', () => {
|
|
162
|
+
const { env } = sandbox('file-roundtrip')
|
|
163
|
+
const store = new FileStore(env, { platform: 'linux' })
|
|
164
|
+
expect(store.load()).toBeNull()
|
|
165
|
+
store.save(SAMPLE)
|
|
166
|
+
const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
|
|
167
|
+
expect(existsSync(file)).toBe(true)
|
|
168
|
+
if (process.platform !== 'win32') {
|
|
169
|
+
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
170
|
+
}
|
|
171
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(SAMPLE)
|
|
172
|
+
expect(store.load()).toEqual(SAMPLE)
|
|
173
|
+
store.save(OTHER)
|
|
174
|
+
expect(store.load()).toEqual(OTHER)
|
|
175
|
+
store.clear()
|
|
176
|
+
expect(existsSync(file)).toBe(false)
|
|
177
|
+
expect(store.load()).toBeNull()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('treats malformed JSON as missing', () => {
|
|
181
|
+
const { env } = sandbox('file-malformed')
|
|
182
|
+
const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
|
|
183
|
+
writeRaw(file, '{not-json')
|
|
184
|
+
expect(new FileStore(env).load()).toBeNull()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('treats incomplete objects as missing', () => {
|
|
188
|
+
const { env } = sandbox('file-incomplete')
|
|
189
|
+
const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
|
|
190
|
+
writeRaw(file, `${JSON.stringify({ machineId: 'mach_fixture' })}\n`)
|
|
191
|
+
expect(new FileStore(env).load()).toBeNull()
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('WindowsDpapiStore', () => {
|
|
196
|
+
it('protects under LOCALAPPDATA and never writes roaming or config trees', () => {
|
|
197
|
+
const { env, roaming, config } = sandbox('dpapi-location')
|
|
198
|
+
const { runner, calls } = mockDpapi()
|
|
199
|
+
const store = new WindowsDpapiStore(env, { run: runner })
|
|
200
|
+
store.save(SAMPLE)
|
|
201
|
+
const file = path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')
|
|
202
|
+
expect(existsSync(file)).toBe(true)
|
|
203
|
+
const onDisk = readFileSync(file, 'utf8')
|
|
204
|
+
expect(onDisk).not.toContain(SAMPLE.secret)
|
|
205
|
+
expect(onDisk).not.toContain(SAMPLE.machineId)
|
|
206
|
+
expect(JSON.parse(onDisk)).toMatchObject({ format: 'notifai.dpapi.current-user.v1' })
|
|
207
|
+
expect(readdirSync(roaming, { recursive: true })).toEqual([])
|
|
208
|
+
expect(existsSync(config) ? readdirSync(config, { recursive: true }) : []).toEqual([])
|
|
209
|
+
expect(store.load()).toEqual(SAMPLE)
|
|
210
|
+
expect(store.describe()).toBe(`Windows DPAPI current-user (${file})`)
|
|
211
|
+
expect(calls[0]?.command).toMatch(/powershell\.exe$/i)
|
|
212
|
+
expect(calls[0]?.args).toEqual(
|
|
213
|
+
expect.arrayContaining(['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass']),
|
|
214
|
+
)
|
|
215
|
+
assertNoSecretInArgs(calls)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('overwrites and clears the protected blob', () => {
|
|
219
|
+
const { env } = sandbox('dpapi-overwrite')
|
|
220
|
+
const { runner, calls } = mockDpapi()
|
|
221
|
+
const store = new WindowsDpapiStore(env, { run: runner })
|
|
222
|
+
store.save(SAMPLE)
|
|
223
|
+
store.save(OTHER)
|
|
224
|
+
expect(store.load()).toEqual(OTHER)
|
|
225
|
+
store.clear()
|
|
226
|
+
expect(store.load()).toBeNull()
|
|
227
|
+
expect(
|
|
228
|
+
existsSync(path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')),
|
|
229
|
+
).toBe(false)
|
|
230
|
+
assertNoSecretInArgs(calls)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('returns null for missing, malformed, and corrupt envelopes', () => {
|
|
234
|
+
const { env } = sandbox('dpapi-corrupt')
|
|
235
|
+
const { runner } = mockDpapi()
|
|
236
|
+
const store = new WindowsDpapiStore(env, { run: runner })
|
|
237
|
+
expect(store.load()).toBeNull()
|
|
238
|
+
const file = path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')
|
|
239
|
+
writeRaw(file, 'not-json')
|
|
240
|
+
expect(store.load()).toBeNull()
|
|
241
|
+
writeRaw(file, `${JSON.stringify({ format: 'unknown', data: 'AAAA' })}\n`)
|
|
242
|
+
expect(store.load()).toBeNull()
|
|
243
|
+
writeRaw(
|
|
244
|
+
file,
|
|
245
|
+
`${JSON.stringify({ format: 'notifai.dpapi.current-user.v1', data: '@@@' })}\n`,
|
|
246
|
+
)
|
|
247
|
+
expect(store.load()).toBeNull()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('leaves the existing blob in place when protection fails', () => {
|
|
251
|
+
const { env } = sandbox('dpapi-protect-fail')
|
|
252
|
+
const good = mockDpapi()
|
|
253
|
+
const store = new WindowsDpapiStore(env, { run: good.runner })
|
|
254
|
+
store.save(SAMPLE)
|
|
255
|
+
const failing = mockDpapi({ failProtect: true })
|
|
256
|
+
const broken = new WindowsDpapiStore(env, { run: failing.runner })
|
|
257
|
+
expect(() => broken.save(OTHER)).toThrow(/Windows credential protection failed/)
|
|
258
|
+
expect(store.load()).toEqual(SAMPLE)
|
|
259
|
+
const onDisk = readFileSync(path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi'), 'utf8')
|
|
260
|
+
expect(onDisk).not.toContain(OTHER.secret)
|
|
261
|
+
assertNoSecretInArgs([...good.calls, ...failing.calls])
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('returns null when unprotect fails', () => {
|
|
265
|
+
const { env } = sandbox('dpapi-unprotect-fail')
|
|
266
|
+
const saved = mockDpapi()
|
|
267
|
+
new WindowsDpapiStore(env, { run: saved.runner }).save(SAMPLE)
|
|
268
|
+
const failing = mockDpapi({ failUnprotect: true })
|
|
269
|
+
expect(new WindowsDpapiStore(env, { run: failing.runner }).load()).toBeNull()
|
|
270
|
+
assertNoSecretInArgs([...saved.calls, ...failing.calls])
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('refuses to save when LOCALAPPDATA is missing', () => {
|
|
274
|
+
const store = new WindowsDpapiStore({}, { run: mockDpapi().runner })
|
|
275
|
+
expect(store.load()).toBeNull()
|
|
276
|
+
expect(() => store.save(SAMPLE)).toThrow(/LOCALAPPDATA is missing/)
|
|
277
|
+
expect(store.describe()).toBe('Windows DPAPI current-user (LOCALAPPDATA unset)')
|
|
278
|
+
store.clear()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('does not invoke a process for a missing or corrupt file', () => {
|
|
282
|
+
const { env } = sandbox('dpapi-no-process')
|
|
283
|
+
const { runner, calls } = mockDpapi()
|
|
284
|
+
const store = new WindowsDpapiStore(env, { run: runner })
|
|
285
|
+
expect(store.load()).toBeNull()
|
|
286
|
+
const file = path.join(env.LOCALAPPDATA!, 'notifai', 'credentials.dpapi')
|
|
287
|
+
writeRaw(file, 'corrupt')
|
|
288
|
+
expect(store.load()).toBeNull()
|
|
289
|
+
expect(calls).toEqual([])
|
|
290
|
+
})
|
|
291
|
+
})
|
package/src/credentials.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process'
|
|
2
|
-
import {
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import { atomicWriteFileSync } from './atomic-file.js'
|
|
4
5
|
import { globalConfigDir } from './config.js'
|
|
5
6
|
|
|
6
7
|
export interface MachineCredential {
|
|
@@ -18,7 +19,94 @@ export interface CredentialStore {
|
|
|
18
19
|
describe(): string
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
export interface RunCommandSpec {
|
|
23
|
+
command: string
|
|
24
|
+
args: readonly string[]
|
|
25
|
+
input: Buffer
|
|
26
|
+
timeoutMs?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RunCommandResult {
|
|
30
|
+
status: number
|
|
31
|
+
stdout: Buffer
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Injected process seam. Production uses `defaultProcessRunner`. */
|
|
35
|
+
export interface ProcessRunner {
|
|
36
|
+
run(spec: RunCommandSpec): RunCommandResult
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CredentialStoreOptions {
|
|
40
|
+
env?: NodeJS.ProcessEnv
|
|
41
|
+
platform?: NodeJS.Platform
|
|
42
|
+
run?: ProcessRunner
|
|
43
|
+
keychainAvailable?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
const SERVICE = 'io.notifai.cli'
|
|
47
|
+
const DPAPI_FORMAT = 'notifai.dpapi.current-user.v1'
|
|
48
|
+
const DPAPI_ENTROPY = 'io.notifai.cli'
|
|
49
|
+
const DPAPI_TIMEOUT_MS = 15_000
|
|
50
|
+
const DPAPI_FILE = 'credentials.dpapi'
|
|
51
|
+
|
|
52
|
+
const PROTECT_SCRIPT = [
|
|
53
|
+
"$ErrorActionPreference = 'Stop'",
|
|
54
|
+
'Add-Type -AssemblyName System.Security',
|
|
55
|
+
'$in = New-Object System.IO.MemoryStream',
|
|
56
|
+
'[Console]::OpenStandardInput().CopyTo($in)',
|
|
57
|
+
`$entropy = [System.Text.Encoding]::UTF8.GetBytes('${DPAPI_ENTROPY}')`,
|
|
58
|
+
'$protected = [System.Security.Cryptography.ProtectedData]::Protect($in.ToArray(), $entropy, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
|
|
59
|
+
'$bytes = [System.Text.Encoding]::ASCII.GetBytes([Convert]::ToBase64String($protected))',
|
|
60
|
+
'[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)',
|
|
61
|
+
].join('; ')
|
|
62
|
+
|
|
63
|
+
const UNPROTECT_SCRIPT = [
|
|
64
|
+
"$ErrorActionPreference = 'Stop'",
|
|
65
|
+
'Add-Type -AssemblyName System.Security',
|
|
66
|
+
'$b64 = [System.Text.Encoding]::ASCII.GetString((& { $in = New-Object System.IO.MemoryStream; [Console]::OpenStandardInput().CopyTo($in); $in.ToArray() })).Trim()',
|
|
67
|
+
'$protected = [Convert]::FromBase64String($b64)',
|
|
68
|
+
`$entropy = [System.Text.Encoding]::UTF8.GetBytes('${DPAPI_ENTROPY}')`,
|
|
69
|
+
'$plain = [System.Security.Cryptography.ProtectedData]::Unprotect($protected, $entropy, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
|
|
70
|
+
'[Console]::OpenStandardOutput().Write($plain, 0, $plain.Length)',
|
|
71
|
+
].join('; ')
|
|
72
|
+
|
|
73
|
+
const PROTECT_ENCODED = encodePowerShell(PROTECT_SCRIPT)
|
|
74
|
+
const UNPROTECT_ENCODED = encodePowerShell(UNPROTECT_SCRIPT)
|
|
75
|
+
|
|
76
|
+
function encodePowerShell(script: string): string {
|
|
77
|
+
return Buffer.from(script, 'utf16le').toString('base64')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function powerShellInvocation(encoded: string, env: NodeJS.ProcessEnv): { command: string; args: string[] } {
|
|
81
|
+
const root = env['SystemRoot'] || env['SYSTEMROOT'] || 'C:\\Windows'
|
|
82
|
+
return {
|
|
83
|
+
command: path.win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
|
84
|
+
args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Which DPAPI operation a process invocation asked for. Args never carry secrets. */
|
|
89
|
+
export function windowsDpapiOperation(args: readonly string[]): 'protect' | 'unprotect' | null {
|
|
90
|
+
const index = args.indexOf('-EncodedCommand')
|
|
91
|
+
const encoded = index >= 0 ? args[index + 1] : undefined
|
|
92
|
+
if (encoded === PROTECT_ENCODED) return 'protect'
|
|
93
|
+
if (encoded === UNPROTECT_ENCODED) return 'unprotect'
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const defaultProcessRunner: ProcessRunner = {
|
|
98
|
+
run(spec) {
|
|
99
|
+
const result = spawnSync(spec.command, [...spec.args], {
|
|
100
|
+
input: spec.input,
|
|
101
|
+
timeout: spec.timeoutMs ?? DPAPI_TIMEOUT_MS,
|
|
102
|
+
maxBuffer: 1024 * 1024,
|
|
103
|
+
windowsHide: true,
|
|
104
|
+
})
|
|
105
|
+
const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0)
|
|
106
|
+
if (result.error) return { status: 1, stdout: Buffer.alloc(0) }
|
|
107
|
+
return { status: result.status ?? 1, stdout }
|
|
108
|
+
},
|
|
109
|
+
}
|
|
22
110
|
|
|
23
111
|
/** macOS Keychain via the `security` CLI — no native addons (proposal concern). */
|
|
24
112
|
export class KeychainStore implements CredentialStore {
|
|
@@ -31,7 +119,7 @@ export class KeychainStore implements CredentialStore {
|
|
|
31
119
|
)
|
|
32
120
|
.toString()
|
|
33
121
|
.trim()
|
|
34
|
-
return
|
|
122
|
+
return parseCredentialJson(raw)
|
|
35
123
|
} catch {
|
|
36
124
|
return null
|
|
37
125
|
}
|
|
@@ -69,29 +157,43 @@ export class KeychainStore implements CredentialStore {
|
|
|
69
157
|
}
|
|
70
158
|
}
|
|
71
159
|
|
|
72
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Explicit plaintext file store.
|
|
162
|
+
*
|
|
163
|
+
* Default on Linux, where mode 0600 is a real ACL. Also the
|
|
164
|
+
* `NOTIFAI_CREDENTIALS=file` development and test escape hatch on every
|
|
165
|
+
* platform. POSIX mode bits are not an NTFS ACL, so this is not a protected
|
|
166
|
+
* Windows store.
|
|
167
|
+
*/
|
|
73
168
|
export class FileStore implements CredentialStore {
|
|
74
|
-
|
|
169
|
+
private readonly env: NodeJS.ProcessEnv
|
|
170
|
+
private readonly platform: NodeJS.Platform
|
|
171
|
+
|
|
172
|
+
constructor(env: NodeJS.ProcessEnv = process.env, options: CredentialStoreOptions = {}) {
|
|
173
|
+
this.env = env
|
|
174
|
+
this.platform = options.platform ?? process.platform
|
|
175
|
+
}
|
|
75
176
|
|
|
76
177
|
private filePath(): string {
|
|
77
|
-
return path.join(globalConfigDir(this.env), 'credentials.json')
|
|
178
|
+
return path.join(globalConfigDir(this.env, this.platform), 'credentials.json')
|
|
78
179
|
}
|
|
79
180
|
|
|
80
181
|
load(): MachineCredential | null {
|
|
81
182
|
const file = this.filePath()
|
|
82
183
|
if (!existsSync(file)) return null
|
|
83
184
|
try {
|
|
84
|
-
return
|
|
185
|
+
return parseCredentialJson(readFileSync(file, 'utf8'))
|
|
85
186
|
} catch {
|
|
86
187
|
return null
|
|
87
188
|
}
|
|
88
189
|
}
|
|
89
190
|
|
|
90
191
|
save(credential: MachineCredential): void {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
192
|
+
atomicWriteFileSync(this.filePath(), `${JSON.stringify(credential, null, 2)}\n`, {
|
|
193
|
+
mode: 0o600,
|
|
194
|
+
preserveMode: false,
|
|
195
|
+
requireCurrentUserOwner: true,
|
|
196
|
+
})
|
|
95
197
|
}
|
|
96
198
|
|
|
97
199
|
clear(): void {
|
|
@@ -99,12 +201,157 @@ export class FileStore implements CredentialStore {
|
|
|
99
201
|
}
|
|
100
202
|
|
|
101
203
|
describe(): string {
|
|
102
|
-
|
|
204
|
+
const file = this.filePath()
|
|
205
|
+
if (this.platform === 'win32') {
|
|
206
|
+
return `plaintext file ${file} (NOTIFAI_CREDENTIALS=file; POSIX mode bits are not protection on NTFS)`
|
|
207
|
+
}
|
|
208
|
+
return `plaintext file ${file} (mode 0600)`
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Windows default: DPAPI CurrentUser via first-party PowerShell.
|
|
214
|
+
*
|
|
215
|
+
* The protected blob lives under LOCALAPPDATA, never roaming APPDATA.
|
|
216
|
+
* Plaintext crosses the process boundary only on stdin/stdout — never as a
|
|
217
|
+
* command-line argument.
|
|
218
|
+
*/
|
|
219
|
+
export class WindowsDpapiStore implements CredentialStore {
|
|
220
|
+
private readonly env: NodeJS.ProcessEnv
|
|
221
|
+
private readonly run: ProcessRunner
|
|
222
|
+
|
|
223
|
+
constructor(env: NodeJS.ProcessEnv = process.env, options: CredentialStoreOptions = {}) {
|
|
224
|
+
this.env = env
|
|
225
|
+
this.run = options.run ?? defaultProcessRunner
|
|
103
226
|
}
|
|
227
|
+
|
|
228
|
+
private filePath(): string | null {
|
|
229
|
+
const local = this.env['LOCALAPPDATA']
|
|
230
|
+
if (typeof local !== 'string' || local.trim() === '') return null
|
|
231
|
+
return path.join(local, 'notifai', DPAPI_FILE)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
load(): MachineCredential | null {
|
|
235
|
+
const file = this.filePath()
|
|
236
|
+
if (file === null || !existsSync(file)) return null
|
|
237
|
+
let text: string
|
|
238
|
+
try {
|
|
239
|
+
text = readFileSync(file, 'utf8')
|
|
240
|
+
} catch {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
const blob = parseDpapiEnvelope(text)
|
|
244
|
+
if (blob === null) return null
|
|
245
|
+
const plain = this.unprotect(blob)
|
|
246
|
+
return plain === null ? null : parseCredentialJson(plain)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
save(credential: MachineCredential): void {
|
|
250
|
+
const file = this.filePath()
|
|
251
|
+
if (file === null) {
|
|
252
|
+
throw new Error('Windows credential store path is not configured (LOCALAPPDATA is missing)')
|
|
253
|
+
}
|
|
254
|
+
const blob = this.protect(Buffer.from(JSON.stringify(credential), 'utf8'))
|
|
255
|
+
atomicWriteFileSync(file, `${JSON.stringify({ format: DPAPI_FORMAT, data: blob })}\n`, {
|
|
256
|
+
mode: 0o600,
|
|
257
|
+
preserveMode: false,
|
|
258
|
+
requireCurrentUserOwner: true,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
clear(): void {
|
|
263
|
+
const file = this.filePath()
|
|
264
|
+
if (file === null) return
|
|
265
|
+
rmSync(file, { force: true })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
describe(): string {
|
|
269
|
+
const file = this.filePath()
|
|
270
|
+
if (file === null) return 'Windows DPAPI current-user (LOCALAPPDATA unset)'
|
|
271
|
+
return `Windows DPAPI current-user (${file})`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private protect(plaintext: Buffer): string {
|
|
275
|
+
const result = this.invoke('protect', plaintext)
|
|
276
|
+
if (result.status !== 0) {
|
|
277
|
+
throw new Error('Windows credential protection failed')
|
|
278
|
+
}
|
|
279
|
+
const encoded = result.stdout.toString('utf8').trim()
|
|
280
|
+
if (!isBase64(encoded)) {
|
|
281
|
+
throw new Error('Windows credential protection failed')
|
|
282
|
+
}
|
|
283
|
+
return encoded
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private unprotect(blob: string): string | null {
|
|
287
|
+
const result = this.invoke('unprotect', Buffer.from(blob, 'utf8'))
|
|
288
|
+
if (result.status !== 0) return null
|
|
289
|
+
const plain = result.stdout.toString('utf8')
|
|
290
|
+
return plain === '' ? null : plain
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private invoke(operation: 'protect' | 'unprotect', input: Buffer): RunCommandResult {
|
|
294
|
+
const encoded = operation === 'protect' ? PROTECT_ENCODED : UNPROTECT_ENCODED
|
|
295
|
+
const invocation = powerShellInvocation(encoded, this.env)
|
|
296
|
+
return this.run.run({
|
|
297
|
+
command: invocation.command,
|
|
298
|
+
args: invocation.args,
|
|
299
|
+
input,
|
|
300
|
+
timeoutMs: DPAPI_TIMEOUT_MS,
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function parseCredentialJson(raw: string): MachineCredential | null {
|
|
306
|
+
try {
|
|
307
|
+
return parseCredential(JSON.parse(raw) as unknown)
|
|
308
|
+
} catch {
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function parseCredential(raw: unknown): MachineCredential | null {
|
|
314
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
315
|
+
const value = raw as Record<string, unknown>
|
|
316
|
+
const machineId = value['machineId']
|
|
317
|
+
const secret = value['secret']
|
|
318
|
+
const baseUrl = value['baseUrl']
|
|
319
|
+
const machineName = value['machineName']
|
|
320
|
+
if (
|
|
321
|
+
typeof machineId !== 'string' ||
|
|
322
|
+
machineId === '' ||
|
|
323
|
+
typeof secret !== 'string' ||
|
|
324
|
+
secret === '' ||
|
|
325
|
+
typeof baseUrl !== 'string' ||
|
|
326
|
+
baseUrl === '' ||
|
|
327
|
+
typeof machineName !== 'string' ||
|
|
328
|
+
machineName === ''
|
|
329
|
+
) {
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
return { machineId, secret, baseUrl, machineName }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function parseDpapiEnvelope(text: string): string | null {
|
|
336
|
+
let parsed: unknown
|
|
337
|
+
try {
|
|
338
|
+
parsed = JSON.parse(text)
|
|
339
|
+
} catch {
|
|
340
|
+
return null
|
|
341
|
+
}
|
|
342
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
343
|
+
const value = parsed as Record<string, unknown>
|
|
344
|
+
if (value['format'] !== DPAPI_FORMAT) return null
|
|
345
|
+
const data = value['data']
|
|
346
|
+
if (typeof data !== 'string' || !isBase64(data)) return null
|
|
347
|
+
return data
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function isBase64(value: string): boolean {
|
|
351
|
+
return value.length > 0 && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
|
|
104
352
|
}
|
|
105
353
|
|
|
106
|
-
function
|
|
107
|
-
if (process.platform !== 'darwin') return false
|
|
354
|
+
function keychainProbeSucceeds(): boolean {
|
|
108
355
|
try {
|
|
109
356
|
execFileSync('security', ['help'], { stdio: 'ignore' })
|
|
110
357
|
return true
|
|
@@ -113,7 +360,17 @@ function keychainAvailable(): boolean {
|
|
|
113
360
|
}
|
|
114
361
|
}
|
|
115
362
|
|
|
116
|
-
export function defaultCredentialStore(
|
|
117
|
-
|
|
118
|
-
|
|
363
|
+
export function defaultCredentialStore(
|
|
364
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
365
|
+
options: CredentialStoreOptions = {},
|
|
366
|
+
): CredentialStore {
|
|
367
|
+
const platform = options.platform ?? process.platform
|
|
368
|
+
if (env['NOTIFAI_CREDENTIALS'] === 'file') return new FileStore(env, { platform })
|
|
369
|
+
if (platform === 'win32') return new WindowsDpapiStore(env, options)
|
|
370
|
+
const keychain =
|
|
371
|
+
options.keychainAvailable !== undefined
|
|
372
|
+
? options.keychainAvailable
|
|
373
|
+
: platform === 'darwin' && keychainProbeSucceeds()
|
|
374
|
+
if (keychain) return new KeychainStore()
|
|
375
|
+
return new FileStore(env, { platform })
|
|
119
376
|
}
|