Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 24 additions & 195 deletions apps/daemon/src/byok/credential-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { access, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { access, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type {
ByokChatProviderConfig,
Expand Down Expand Up @@ -346,7 +346,9 @@ export function createPlatformByokSecretBackend(
if (platform === 'darwin') return new MacOsKeychainBackend();
if (platform === 'linux') return new LinuxSecretServiceBackend();
if (platform === 'win32' && dataDir) {
return new WindowsDpapiBackend(path.join(dataDir, 'byok', 'secrets'));
return new RetiredWindowsSecretCleanupBackend(
path.join(dataDir, 'byok', 'secrets'),
);
}
return new UnavailableSecretBackend(platform);
}
Expand Down Expand Up @@ -435,137 +437,6 @@ class LinuxSecretServiceBackend implements ByokSecretBackend {
}
}

const WINDOWS_DPAPI_SCRIPT = `
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Security
$operation = $env:OD_BYOK_DPAPI_OPERATION
$secretPath = $env:OD_BYOK_DPAPI_PATH

try {
switch ($operation) {
'probe' {
$plain = [System.Text.Encoding]::UTF8.GetBytes('open-design-dpapi-probe')
$cipher = [System.Security.Cryptography.ProtectedData]::Protect(
$plain,
$null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
$roundTrip = [System.Security.Cryptography.ProtectedData]::Unprotect(
$cipher,
$null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
if ([System.Text.Encoding]::UTF8.GetString($roundTrip) -ne 'open-design-dpapi-probe') {
throw 'DPAPI probe failed'
}
}
'set' {
$secret = [Console]::In.ReadToEnd()
if ([string]::IsNullOrWhiteSpace($secret)) {
throw 'Secret must not be empty'
}
$plain = [System.Text.Encoding]::UTF8.GetBytes($secret)
$cipher = [System.Security.Cryptography.ProtectedData]::Protect(
$plain,
$null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
$directory = [System.IO.Path]::GetDirectoryName($secretPath)
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
$temporaryPath = "$secretPath.$([Guid]::NewGuid().ToString('N')).tmp"
try {
[System.IO.File]::WriteAllBytes($temporaryPath, $cipher)
Move-Item -LiteralPath $temporaryPath -Destination $secretPath -Force
} finally {
if (Test-Path -LiteralPath $temporaryPath) {
Remove-Item -LiteralPath $temporaryPath -Force
}
}
}
'get' {
if (-not (Test-Path -LiteralPath $secretPath -PathType Leaf)) {
exit 44
}
$cipher = [System.IO.File]::ReadAllBytes($secretPath)
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
$cipher,
$null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
[Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($plain))
}
'delete' {
if (-not (Test-Path -LiteralPath $secretPath -PathType Leaf)) {
exit 44
}
Remove-Item -LiteralPath $secretPath -Force
}
default {
throw 'Unsupported DPAPI operation'
}
}
} catch {
[Console]::Error.WriteLine('Open Design secure credential operation failed.')
exit 1
}
`;

const WINDOWS_DPAPI_ENCODED_SCRIPT = Buffer.from(
WINDOWS_DPAPI_SCRIPT,
'utf16le',
).toString('base64');

class WindowsDpapiBackend implements ByokSecretBackend {
readonly kind = 'windows-dpapi';
private availability: Promise<boolean> | null = null;

constructor(private readonly secretsDir: string) {}

async available() {
this.availability ??= this.probeAvailability();
return this.availability;
}

private async probeAvailability() {
if (!(await commandAvailable('powershell.exe'))) return false;
try {
await runWindowsDpapiCommand('probe', this.secretsDir);
return true;
} catch {
return false;
}
}

async set(profileId: string, secret: string) {
assertProfileId(profileId);
await runWindowsDpapiCommand(
'set',
path.join(this.secretsDir, `${profileId}.bin`),
secret,
);
}

async get(profileId: string) {
assertProfileId(profileId);
return runWindowsDpapiCommand(
'get',
path.join(this.secretsDir, `${profileId}.bin`),
undefined,
true,
);
}

async delete(profileId: string) {
assertProfileId(profileId);
return (await runWindowsDpapiCommand(
'delete',
path.join(this.secretsDir, `${profileId}.bin`),
undefined,
true,
)) !== null;
}
}

class UnavailableSecretBackend implements ByokSecretBackend {
readonly kind: string;

Expand All @@ -574,70 +445,28 @@ class UnavailableSecretBackend implements ByokSecretBackend {
}

async available() { return false; }
async set() { throw new Error('Secure credential storage is unavailable on this system.'); }
async get() { return null; }
async delete() { return false; }
async set(_profileId: string, _secret: string) {
throw new Error('Secure credential storage is unavailable on this system.');
}
async get(_profileId: string) { return null; }
async delete(_profileId: string) { return false; }
}

async function runWindowsDpapiCommand(
operation: 'probe' | 'set' | 'get' | 'delete',
secretPath: string,
secretInput?: string,
allowNotFound = false,
): Promise<string | null> {
return new Promise((resolve, reject) => {
const child = spawn('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
WINDOWS_DPAPI_ENCODED_SCRIPT,
], {
env: {
...process.env,
OD_BYOK_DPAPI_OPERATION: operation,
OD_BYOK_DPAPI_PATH: secretPath,
},
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
const stdout: Buffer[] = [];
let stdoutBytes = 0;
let settled = false;
const finish = (error: Error | null, output?: string | null) => {
if (settled) return;
settled = true;
if (error) reject(error);
else resolve(output === undefined ? '' : output);
};
child.stdout.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes <= MAX_SECRET_OUTPUT_BYTES) stdout.push(chunk);
});
child.stderr.resume();
child.on('error', () => {
finish(new Error('Secure credential backend command failed.'));
});
child.on('close', (code) => {
if (stdoutBytes > MAX_SECRET_OUTPUT_BYTES) {
finish(new Error('Secure credential backend command failed.'));
return;
}
if (code === 0) {
finish(null, Buffer.concat(stdout).toString('utf8'));
return;
}
if (allowNotFound && code === 44) {
finish(null, null);
return;
}
finish(new Error('Secure credential backend command failed.'));
});
if (secretInput === undefined) child.stdin.end();
else child.stdin.end(secretInput);
});
class RetiredWindowsSecretCleanupBackend extends UnavailableSecretBackend {
constructor(private readonly secretsDir: string) {
super('win32');
}

override async delete(profileId: string) {
assertProfileId(profileId);
try {
await unlink(path.join(this.secretsDir, `${profileId}.bin`));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw error;
}
}
}

async function commandAvailable(command: string): Promise<boolean> {
Expand Down
40 changes: 36 additions & 4 deletions apps/daemon/tests/byok/credential-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,45 @@ describe('BYOK credential service', () => {
})).rejects.toThrow(/secure credential storage is unavailable/i);
});

it('dispatches native Windows credentials to a DPAPI backend rooted in OD_DATA_DIR', async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), 'od-byok-windows-dispatch-'));
it('fails closed when Windows has no supported secure credential backend', async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), 'od-byok-credentials-'));
roots.push(dataDir);

const backend = createPlatformByokSecretBackend('win32', dataDir);

expect(backend.kind).toBe('windows-dpapi');
expect(backend.kind).toBe('unavailable-win32');
await expect(backend.available()).resolves.toBe(false);
});

it('deletes a retired Windows DPAPI blob with its profile metadata', async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), 'od-byok-credentials-'));
roots.push(dataDir);
const profileId = 'byok-retired-windows';
const byokDir = path.join(dataDir, 'byok');
const secretsDir = path.join(byokDir, 'secrets');
const secretPath = path.join(secretsDir, `${profileId}.bin`);
await mkdir(secretsDir, { recursive: true });
await writeFile(path.join(byokDir, 'profiles.json'), JSON.stringify({
version: 1,
profiles: [{
id: profileId,
label: 'Retired Windows profile',
protocol: 'openai',
baseUrl: 'https://example.test/v1',
model: 'model',
requiresApiKey: true,
createdAt: 1,
updatedAt: 1,
}],
}));
await writeFile(secretPath, 'retired-dpapi-blob');
const service = new ByokCredentialService({
dataDir,
backend: createPlatformByokSecretBackend('win32', dataDir),
});

await expect(service.delete(profileId)).resolves.toBe(true);
await expect(service.get(profileId)).resolves.toBeNull();
await expect(readFile(secretPath)).rejects.toMatchObject({ code: 'ENOENT' });
});

it('serializes concurrent metadata mutations so profiles cannot overwrite each other', async () => {
Expand Down
64 changes: 0 additions & 64 deletions apps/daemon/tests/byok/credential-service.windows.test.ts

This file was deleted.

Loading
Loading