spaps
Version:
Sweet Potato Authentication & Payment Service CLI - Docker Compose orchestrator for local Python/FastAPI SPAPS server with built-in admin middleware
488 lines (452 loc) • 15.1 kB
JavaScript
// Keyring-first credential storage for the SPAPS CLI.
//
// Interactive login mirrors tokens to the OS keyring and an AES-GCM encrypted
// file at ~/.config/spaps/credentials.json. Ordinary commands only use an OS
// store after a prompt-free unlocked-state probe; otherwise they use the
// encrypted mirror without invoking an unlock flow.
// Uses a separate filename from the Python client's ~/.config/spaps/tokens.json
// to avoid schema collisions; cross-client unification can come later.
const crypto = require('node:crypto');
const childProcess = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const SCHEMA_VERSION = 2;
const CIPHER = 'aes-256-gcm';
const KEYRING_SERVICE = 'spaps-cli';
const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
const DEFAULT_STALE_LOCK_MS = 30_000;
function defaultCredentialsPath() {
if (process.env.SPAPS_CREDENTIALS_PATH) {
return process.env.SPAPS_CREDENTIALS_PATH;
}
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
return path.join(base, 'spaps', 'credentials.json');
}
function normalizeServerUrl(url) {
if (!url) return '';
return String(url).trim().replace(/\/+$/, '').toLowerCase();
}
function createSystemKeyring({
platform = process.platform,
spawnSync = childProcess.spawnSync,
} = {}) {
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
encoding: 'utf8',
timeout: 5_000,
windowsHide: true,
...options,
});
if (result.error) throw result.error;
return result;
}
if (platform === 'darwin') {
const isUnlocked = () =>
run('/usr/bin/security', ['show-keychain-info']).status === 0;
const get = (account) => {
const result = run('/usr/bin/security', [
'find-generic-password', '-s', KEYRING_SERVICE, '-a', account, '-w',
]);
if (result.status !== 0) return null;
return String(result.stdout || '').replace(/\r?\n$/, '');
};
const set = (account, value) => {
const result = run('/usr/bin/security', [
'add-generic-password', '-U', '-s', KEYRING_SERVICE,
'-a', account, '-w', value,
]);
if (result.status !== 0) throw new Error('macOS Keychain write failed');
return true;
};
const deletePassword = (account) => {
const result = run('/usr/bin/security', [
'delete-generic-password', '-s', KEYRING_SERVICE, '-a', account,
]);
return result.status === 0;
};
return {
get,
set,
delete: deletePassword,
getNonInteractive: (account) => isUnlocked() ? get(account) : null,
setNonInteractive: (account, value) => isUnlocked() ? set(account, value) : false,
deleteNonInteractive: (account) => isUnlocked() ? deletePassword(account) : false,
};
}
if (platform === 'linux') {
const isUnlocked = () => {
const result = run('gdbus', [
'call', '--session', '--dest', 'org.freedesktop.secrets',
'--object-path', '/org/freedesktop/secrets/aliases/default',
'--method', 'org.freedesktop.DBus.Properties.Get',
'org.freedesktop.Secret.Collection', 'Locked',
]);
return result.status === 0 && /false/i.test(String(result.stdout || ''));
};
const get = (account) => {
const result = run('secret-tool', [
'lookup', 'service', KEYRING_SERVICE, 'server', account,
]);
if (result.status !== 0) return null;
return String(result.stdout || '').replace(/\r?\n$/, '');
};
const set = (account, value) => {
const result = run('secret-tool', [
'store', '--label', `SPAPS CLI (${account})`,
'service', KEYRING_SERVICE, 'server', account,
], { input: value });
if (result.status !== 0) throw new Error('Secret Service write failed');
return true;
};
const deletePassword = (account) => {
const result = run('secret-tool', [
'clear', 'service', KEYRING_SERVICE, 'server', account,
]);
return result.status === 0;
};
return {
get,
set,
delete: deletePassword,
getNonInteractive: (account) => isUnlocked() ? get(account) : null,
setNonInteractive: (account, value) => isUnlocked() ? set(account, value) : false,
deleteNonInteractive: (account) => isUnlocked() ? deletePassword(account) : false,
};
}
return null;
}
function emptyStore() {
return { version: SCHEMA_VERSION, servers: {}, tombstones: {} };
}
function lockPathFor(target) {
return `${target}.lock`;
}
function isLockStale(lockPath, maxAgeMs = DEFAULT_STALE_LOCK_MS) {
try {
const stat = fs.statSync(lockPath);
return Date.now() - stat.mtimeMs > maxAgeMs;
} catch {
return false;
}
}
function spinWait(ms) {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
// Busy-wait briefly; credential ops are short-lived.
}
}
function acquireLockSync(
target,
timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
staleLockMs = DEFAULT_STALE_LOCK_MS
) {
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
const lockPath = lockPathFor(target);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const fd = fs.openSync(lockPath, 'wx');
fs.writeSync(
fd,
JSON.stringify({ pid: process.pid, acquired_at: Date.now() })
);
fs.closeSync(fd);
return lockPath;
} catch (err) {
if (err.code !== 'EEXIST') {
throw err;
}
if (isLockStale(lockPath, staleLockMs)) {
try {
fs.unlinkSync(lockPath);
} catch {
// Another process may have released the lock; retry.
}
continue;
}
spinWait(50);
}
}
const lockErr = new Error('Timed out acquiring credentials lock');
lockErr.code = 'CREDENTIALS_LOCK_TIMEOUT';
throw lockErr;
}
function releaseLockSync(lockPath) {
try {
fs.unlinkSync(lockPath);
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
}
}
function createStore(filePath, options = {}) {
const target = filePath || defaultCredentialsPath();
const keyTarget = options.keyPath || process.env.SPAPS_CREDENTIALS_KEY_PATH || `${target}.key`;
const keyring = Object.prototype.hasOwnProperty.call(options, 'keyring')
? options.keyring
: createSystemKeyring();
let lockDepth = 0;
let activeLockPath = null;
function withCredentialsLock(fn, options = {}) {
if (lockDepth > 0) {
return fn();
}
activeLockPath = acquireLockSync(
target,
options.timeoutMs,
options.staleLockMs
);
lockDepth += 1;
try {
return fn();
} finally {
lockDepth -= 1;
if (lockDepth === 0) {
releaseLockSync(activeLockPath);
activeLockPath = null;
}
}
}
async function withCredentialsLockAsync(fn, options = {}) {
if (lockDepth > 0) {
return await fn();
}
activeLockPath = acquireLockSync(
target,
options.timeoutMs,
options.staleLockMs
);
lockDepth += 1;
try {
return await fn();
} finally {
lockDepth -= 1;
if (lockDepth === 0) {
releaseLockSync(activeLockPath);
activeLockPath = null;
}
}
}
function loadEncryptionKey({ create = false } = {}) {
try {
const raw = fs.readFileSync(keyTarget, 'utf8').trim();
const key = Buffer.from(raw, 'base64');
if (key.length !== 32) throw new Error('invalid key length');
return key;
} catch (err) {
if (err.code !== 'ENOENT' || !create) {
const keyErr = new Error(`Unable to read credential encryption key: ${err.message}`);
keyErr.code = 'CREDENTIALS_KEY_ERROR';
throw keyErr;
}
}
const dir = path.dirname(keyTarget);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const key = crypto.randomBytes(32);
const tmp = `${keyTarget}.${process.pid}.tmp`;
fs.writeFileSync(tmp, `${key.toString('base64')}\n`, { mode: 0o600 });
fs.renameSync(tmp, keyTarget);
try {
fs.chmodSync(keyTarget, 0o600);
} catch {
// Best effort on platforms without POSIX modes.
}
return key;
}
function decryptEnvelope(envelope) {
const key = loadEncryptionKey();
const decipher = crypto.createDecipheriv(
CIPHER,
key,
Buffer.from(envelope.iv, 'base64')
);
decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(envelope.data, 'base64')),
decipher.final(),
]);
return JSON.parse(plaintext.toString('utf8'));
}
function encryptStore(data) {
const key = loadEncryptionKey({ create: true });
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(CIPHER, key, iv);
const encrypted = Buffer.concat([
cipher.update(JSON.stringify(data), 'utf8'),
cipher.final(),
]);
return {
version: SCHEMA_VERSION,
cipher: CIPHER,
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
data: encrypted.toString('base64'),
};
}
function loadAll() {
try {
const raw = fs.readFileSync(target, 'utf8');
const parsed = JSON.parse(raw);
const data = parsed && parsed.cipher === CIPHER
? decryptEnvelope(parsed)
: parsed;
if (!data || typeof data !== 'object') return emptyStore();
if (!data.servers || typeof data.servers !== 'object') data.servers = {};
if (!data.tombstones || typeof data.tombstones !== 'object') data.tombstones = {};
if (!data.version) data.version = SCHEMA_VERSION;
return data;
} catch (err) {
if (err.code === 'ENOENT') return emptyStore();
throw err;
}
}
function saveAll(data) {
const dir = path.dirname(target);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const tmp = `${target}.${process.pid}.tmp`;
const envelope = encryptStore({ ...data, version: SCHEMA_VERSION });
fs.writeFileSync(tmp, `${JSON.stringify(envelope, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tmp, target);
try {
fs.chmodSync(target, 0o600);
} catch {
// Best effort — Windows ignores chmod, and that's OK.
}
}
function keyringMethod(action, allowPrompt) {
if (!keyring) return null;
if (allowPrompt) return keyring[action];
return keyring[`${action}NonInteractive`];
}
function readKeyring(account, allowPrompt) {
if (!keyring) return null;
try {
const method = keyringMethod('get', allowPrompt);
if (!method) return null;
const raw = method.call(keyring, account);
if (!raw) return null;
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
function getCredentials(serverUrl, operationOptions = {}) {
return withCredentialsLock(() => {
const key = normalizeServerUrl(serverUrl);
const keyringCredentials = readKeyring(
key,
Boolean(operationOptions.allowKeyringPrompt)
);
const data = loadAll();
const fallbackCredentials = data.servers[key] || null;
const tombstoneAt = Number(data.tombstones[key] || 0);
const keyringSavedAt = Number(
keyringCredentials?.saved_at_ms ||
(keyringCredentials?.saved_at ? keyringCredentials.saved_at * 1000 : 0)
);
if (tombstoneAt && tombstoneAt >= keyringSavedAt) return null;
if (!keyringCredentials) return fallbackCredentials;
if (!fallbackCredentials) return keyringCredentials;
const fallbackSavedAt = Number(
fallbackCredentials.saved_at_ms ||
(fallbackCredentials.saved_at ? fallbackCredentials.saved_at * 1000 : 0)
);
return keyringSavedAt >= fallbackSavedAt
? keyringCredentials
: fallbackCredentials;
});
}
function setCredentials(serverUrl, creds, operationOptions = {}) {
return withCredentialsLock(() => {
const data = loadAll();
const key = normalizeServerUrl(serverUrl);
const stored = {
...creds,
saved_at: Math.floor(Date.now() / 1000),
saved_at_ms: Date.now(),
};
data.servers[key] = stored;
delete data.tombstones[key];
saveAll(data);
const keyringSet = keyringMethod(
'set',
Boolean(operationOptions.allowKeyringPrompt)
);
if (keyringSet) {
try {
const keyringStored = keyringSet.call(keyring, key, JSON.stringify(stored));
if (keyringStored) {
return { primary: 'keyring', fallback: target };
}
} catch {
// The encrypted file is the durable fallback when the keyring is
// missing, locked, or unavailable in a remote/headless session.
}
}
return { primary: 'encrypted_file', fallback: target };
});
}
function clearCredentials(serverUrl, operationOptions = {}) {
return withCredentialsLock(() => {
const data = loadAll();
const key = normalizeServerUrl(serverUrl);
let removed = false;
if (data.servers[key]) {
delete data.servers[key];
removed = true;
}
// Preserve the local logout decision even when a locked OS keyring
// cannot delete its copy. A later unlocked read must not resurrect it.
data.tombstones[key] = Date.now();
saveAll(data);
const keyringDelete = keyringMethod(
'delete',
Boolean(operationOptions.allowKeyringPrompt)
);
if (keyringDelete) {
try {
removed = keyringDelete.call(keyring, key) || removed;
} catch {
// A locked/unavailable keyring must not prevent local logout.
}
}
return removed;
});
}
return {
path: target,
keyPath: keyTarget,
getCredentials,
setCredentials,
clearCredentials,
withCredentialsLock,
withCredentialsLockAsync,
_loadAll: loadAll,
_saveAll: saveAll,
_acquireLockSync: () => acquireLockSync(target),
_releaseLockSync: (lockPath) => releaseLockSync(lockPath),
_lockPathFor: () => lockPathFor(target),
};
}
// Default singleton used by handlers in normal CLI operation.
const defaultStore = createStore();
module.exports = {
SCHEMA_VERSION,
DEFAULT_LOCK_TIMEOUT_MS,
DEFAULT_STALE_LOCK_MS,
createStore,
createSystemKeyring,
defaultCredentialsPath,
normalizeServerUrl,
lockPathFor,
get CREDENTIALS_PATH() { return defaultStore.path; },
getCredentials: (url, options) => defaultStore.getCredentials(url, options),
setCredentials: (url, creds, options) => defaultStore.setCredentials(url, creds, options),
clearCredentials: (url, options) => defaultStore.clearCredentials(url, options),
withCredentialsLock: (fn, options) => defaultStore.withCredentialsLock(fn, options),
withCredentialsLockAsync: (fn, options) =>
defaultStore.withCredentialsLockAsync(fn, options),
};