UNPKG

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
// 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), };