Files
smartsecret/ts/smartsecret.backends.macos.ts
Juergen Kunz 7a19f01def feat(core): initial release with 3-tier secret storage
Implements SmartSecret with macOS Keychain, Linux secret-tool, and AES-256-GCM encrypted file fallback backends. Zero runtime dependencies.
2026-02-24 15:40:14 +00:00

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 [];
}
}
}