Skip to content
Draft
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
92 changes: 78 additions & 14 deletions src/lib/credential-backends/linux.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { execFile, spawn } from 'node:child_process';
import { access, constants } from 'node:fs/promises';
import type { CredentialBackend } from '../credential-store';

const TRUSTED_SECRET_TOOL_PATHS = [
'/usr/bin/secret-tool',
'/usr/local/bin/secret-tool',
'/bin/secret-tool',
] as const;

const WHICH_PATH = '/usr/bin/which';

const resolvedPaths = new Map<string, string>();

function run(
cmd: string,
args: string[],
Expand Down Expand Up @@ -29,28 +40,78 @@ function runWithStdin(
stdio: ['pipe', 'ignore', 'pipe'],
timeout: 5000,
});
let stderr = '';
const chunks: string[] = [];
child.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
chunks.push(data.toString());
});
child.on('close', (code) => {
resolve({ code, stderr });
resolve({ code, stderr: chunks.join('') });
});
child.on('error', () => {
resolve({ code: 1, stderr: 'Failed to spawn process' });
});
child.stdin?.on('error', () => {}); // Prevent EPIPE crash
child.stdin?.on('error', () => {});
child.stdin?.write(stdin);
child.stdin?.end();
});
}

const findExecutableInTrustedPaths = async (
candidates: readonly string[],
): Promise<string | null> => {
for (const candidate of candidates) {
try {
await access(candidate, constants.X_OK);
return candidate;
} catch {}
}
return null;
};

const resolveSecretTool = async (): Promise<string | null> => {
const cached = resolvedPaths.get('secret-tool');
if (cached) {
return cached;
}

const trusted = await findExecutableInTrustedPaths(TRUSTED_SECRET_TOOL_PATHS);
if (trusted) {
resolvedPaths.set('secret-tool', trusted);
return trusted;
}

const result = await run(WHICH_PATH, ['secret-tool']);
const resolved = result.stdout.trim();
if (result.code === 0 && resolved.startsWith('/')) {
try {
await access(resolved, constants.X_OK);
resolvedPaths.set('secret-tool', resolved);
return resolved;
} catch {
return null;
}
}

return null;
};

const requireSecretTool = async (): Promise<string> => {
const resolved = await resolveSecretTool();
if (!resolved) {
throw new Error(
'secret-tool not found in trusted paths (/usr/bin, /usr/local/bin, /bin)',
);
}
return resolved;
};

export class LinuxBackend implements CredentialBackend {
name = 'Secret Service (libsecret)';
readonly isSecure = true;

async get(service: string, account: string): Promise<string | null> {
const { stdout, code } = await run('secret-tool', [
const cmd = await requireSecretTool();
const { stdout, code } = await run(cmd, [
'lookup',
'service',
service,
Expand All @@ -64,9 +125,9 @@ export class LinuxBackend implements CredentialBackend {
}

async set(service: string, account: string, secret: string): Promise<void> {
// Pass secret via stdin to avoid exposing in process list
const cmd = await requireSecretTool();
const { code, stderr } = await runWithStdin(
'secret-tool',
cmd,
[
'store',
`--label=Resend CLI (${account})`,
Expand All @@ -85,7 +146,8 @@ export class LinuxBackend implements CredentialBackend {
}

async delete(service: string, account: string): Promise<boolean> {
const { code } = await run('secret-tool', [
const cmd = await requireSecretTool();
const { code } = await run(cmd, [
'clear',
'service',
service,
Expand All @@ -99,18 +161,20 @@ export class LinuxBackend implements CredentialBackend {
if (process.platform !== 'linux') {
return false;
}
// Check if secret-tool is installed
const which = await run('which', ['secret-tool']);
if (which.code !== 0) {
const secretTool = await resolveSecretTool();
if (!secretTool) {
return false;
}
// Probe the daemon with a harmless lookup (3s timeout)
const probe = await run(
'secret-tool',
secretTool,
['lookup', 'service', '__resend_cli_probe__'],
{ timeout: 3000 },
);
// exit code 0 or 1 means daemon is responding; timeout or other errors mean it's not
return probe.code === 0 || probe.code === 1;
}
}

export {
resolvedPaths as _resolvedPaths,
resolveSecretTool as _resolveSecretTool,
};
40 changes: 22 additions & 18 deletions src/lib/credential-backends/windows.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { execFile, spawn } from 'node:child_process';
import type { CredentialBackend } from '../credential-store';

const resolvePowershellPath = (): string => {
const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
return `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
};

const POWERSHELL_PATH = resolvePowershellPath();

function runPowershell(
script: string,
): Promise<{ stdout: string; stderr: string; code: number | null }> {
return new Promise((resolve) => {
execFile(
'powershell.exe',
POWERSHELL_PATH,
['-NoProfile', '-NonInteractive', '-Command', script],
{ timeout: 10000 },
(err, stdout, stderr) => {
Expand All @@ -23,38 +30,36 @@ function runPowershellWithStdin(
): Promise<{ stdout: string; stderr: string; code: number | null }> {
return new Promise((resolve) => {
const child = spawn(
'powershell.exe',
POWERSHELL_PATH,
['-NoProfile', '-NonInteractive', '-Command', script],
{ stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000 },
);
let stdout = '';
let stderr = '';
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
child.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
stdoutChunks.push(data.toString());
});
child.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
stderrChunks.push(data.toString());
});
child.on('close', (code) => {
resolve({ stdout, stderr, code });
resolve({
stdout: stdoutChunks.join(''),
stderr: stderrChunks.join(''),
code,
});
});
child.on('error', () => {
resolve({ stdout: '', stderr: 'Failed to spawn process', code: 1 });
});
child.stdin?.on('error', () => {}); // Prevent EPIPE crash
child.stdin?.on('error', () => {});
child.stdin?.write(stdin);
child.stdin?.end();
});
}

// Escape single quotes for PowerShell string literals
function psEscape(s: string): string {
return s.replace(/'/g, "''");
}
const psEscape = (s: string): string => s.replace(/'/g, "''");

// Snippet that ensures the WinRT PasswordVault type is loaded.
// On some environments (e.g. GitHub Actions) the type isn't available by
// default and must be explicitly loaded via its assembly-qualified name.
const LOAD_VAULT = `
try { $null = [Windows.Security.Credentials.PasswordVault] } catch {
[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]
Expand Down Expand Up @@ -84,7 +89,6 @@ export class WindowsBackend implements CredentialBackend {
}

async set(service: string, account: string, secret: string): Promise<void> {
// Remove existing credential first (PasswordVault throws on duplicate)
const removeScript = `${LOAD_VAULT}
$v = New-Object Windows.Security.Credentials.PasswordVault
try {
Expand All @@ -94,7 +98,6 @@ export class WindowsBackend implements CredentialBackend {
`;
await runPowershell(removeScript);

// Read secret from stdin to avoid exposing it in process args
const addScript = `${LOAD_VAULT}
$secret = [Console]::In.ReadLine()
$v = New-Object Windows.Security.Credentials.PasswordVault
Expand Down Expand Up @@ -127,10 +130,11 @@ export class WindowsBackend implements CredentialBackend {
if (process.platform !== 'win32') {
return false;
}
// Test that PowerShell and PasswordVault are accessible
const { code } = await runPowershell(
`${LOAD_VAULT} $null = New-Object Windows.Security.Credentials.PasswordVault`,
);
return code === 0;
}
}

export { POWERSHELL_PATH as _powershellPath };
Loading
Loading