Implements SmartSecret with macOS Keychain, Linux secret-tool, and AES-256-GCM encrypted file fallback backends. Zero runtime dependencies.
108 lines
2.8 KiB
TypeScript
108 lines
2.8 KiB
TypeScript
import * as plugins from './smartsecret.plugins.js';
|
|
import type { ISecretBackend, TBackendType } from './smartsecret.backends.base.js';
|
|
|
|
function execFile(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
plugins.childProcess.execFile(cmd, args, { encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
reject(Object.assign(err, { stdout, stderr }));
|
|
} else {
|
|
resolve({ stdout: stdout as string, stderr: stderr as string });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export class MacosKeychainBackend implements ISecretBackend {
|
|
public readonly backendType: TBackendType = 'macos-keychain';
|
|
private service: string;
|
|
|
|
constructor(service: string) {
|
|
this.service = service;
|
|
}
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
if (process.platform !== 'darwin') return false;
|
|
try {
|
|
await execFile('which', ['security']);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async setSecret(account: string, secret: string): Promise<void> {
|
|
// Delete existing entry first (ignore errors if not found)
|
|
try {
|
|
await execFile('security', [
|
|
'delete-generic-password',
|
|
'-s', this.service,
|
|
'-a', account,
|
|
]);
|
|
} catch {
|
|
// Not found — fine
|
|
}
|
|
|
|
await execFile('security', [
|
|
'add-generic-password',
|
|
'-s', this.service,
|
|
'-a', account,
|
|
'-w', secret,
|
|
'-U',
|
|
]);
|
|
}
|
|
|
|
async getSecret(account: string): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await execFile('security', [
|
|
'find-generic-password',
|
|
'-s', this.service,
|
|
'-a', account,
|
|
'-w',
|
|
]);
|
|
return stdout.trim();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async deleteSecret(account: string): Promise<boolean> {
|
|
try {
|
|
await execFile('security', [
|
|
'delete-generic-password',
|
|
'-s', this.service,
|
|
'-a', account,
|
|
]);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async listAccounts(): Promise<string[]> {
|
|
try {
|
|
const { stdout } = await execFile('security', [
|
|
'dump-keychain',
|
|
]);
|
|
const accounts: string[] = [];
|
|
const serviceRegex = new RegExp(`"svce"<blob>="${this.service.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`);
|
|
const lines = stdout.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (serviceRegex.test(lines[i])) {
|
|
// Look for the account line nearby
|
|
for (let j = Math.max(0, i - 5); j < Math.min(lines.length, i + 5); j++) {
|
|
const match = lines[j].match(/"acct"<blob>="([^"]+)"/);
|
|
if (match) {
|
|
accounts.push(match[1]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return [...new Set(accounts)];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
}
|