Something went wrong. Try again.
A simple DAV sync application / cli
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279import * as FS from 'node:fs';import * as OS from 'node:os';import * as PATH from 'node:path';import { spawnSync } from 'node:child_process';
// Credentials in the operating system's own store, so a WebDAV password is// never written to a file in the clear (taken from ~/Code/dns, which does the// same for API tokens)://// Linux, BSD the Secret Service (libsecret's secret-tool), which GNOME// Keyring and KDE Wallet both answer; kwallet-query for a KDE// Wallet without it// macOS the login keychain (security)// Windows Credential Manager (PowerShell, through CredRead/CredWrite)//// Each is driven through the tool the platform ships rather than a native// module, so davsync stays one plain archive. Secrets go to those tools on stdin,// never on a command line, where other users could read them in `ps`.//// DAVSYNC_KEYCHAIN chooses a store explicitly: secret-service, kwallet, macos,// windows, or file — a JSON file readable only by you, for machines with no// store at all (a server, a container), which is said plainly when used.
export interface Keychain { readonly name: string; /** Where secrets end up, in words. */ readonly description: string; get(key: string): string | undefined; set(key: string, secret: string, label: string): void; delete(key: string): void;}
const SERVICE = 'davsync';
/** * The store exists but cannot be used right now: locked, its daemon not * reachable (a service started before the session's keyring), or its tool * failing. Worth trying again later, unlike a secret that is simply not there. */export class KeychainUnavailable extends Error { constructor(message: string) { super(message); this.name = 'KeychainUnavailable'; }}
let chosen: Keychain | undefined;
/** The store to use here: DAVSYNC_KEYCHAIN's, or the platform's. Throws, saying what to do, when there is none. */export function keychain(): Keychain { chosen ??= pick(); return chosen;}
function pick(): Keychain { const wanted = process.env['DAVSYNC_KEYCHAIN']; if (wanted) { const store = STORES[wanted]; if (!store) throw new Error(`DAVSYNC_KEYCHAIN=${wanted} is not a store davsync knows: ${Object.keys(STORES).join(', ')}`); return store(); } if (process.platform === 'darwin') return macos(); if (process.platform === 'win32') return windows(); if (onPath('secret-tool')) return secretService(); if (onPath('kwallet-query')) return kwallet(); throw new KeychainUnavailable('there is no credential store here: install libsecret\'s secret-tool (with GNOME Keyring or KDE Wallet), ' + 'or set DAVSYNC_KEYCHAIN=file to keep secrets in a file only you can read');}
const STORES: Record<string, () => Keychain> = { 'secret-service': secretService, kwallet, macos, windows, file,};
function onPath(tool: string): boolean { return spawnSync(process.platform === 'win32' ? 'where' : 'which', [tool], { stdio: 'ignore' }).status === 0;}
function run(tool: string, args: string[], input?: string): { status: number | null; stdout: string; stderr: string } { // A locked keyring may put up a prompt to unlock it; a minute is long enough to answer it. const result = spawnSync(tool, args, { input, encoding: 'utf8', timeout: 60_000, windowsHide: true }); if (result.error) throw new KeychainUnavailable(`${tool}: ${result.error.message}`); return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };}
function failure(tool: string, action: string, result: { stderr: string; status: number | null }): Error { return new KeychainUnavailable(`${tool} could not ${action}: ${result.stderr.trim() || `exit ${result.status}`}`);}
// --------------------------------------------------------- Secret Service ---
function secretService(): Keychain { const attributes = (key: string) => ['service', SERVICE, 'key', key]; return { name: 'secret-service', description: 'the Secret Service (your desktop keyring)', get(key) { const result = run('secret-tool', ['lookup', ...attributes(key)]); // Not found is exit 1 with nothing on stderr. if (result.status !== 0) { if (result.stderr.trim()) throw failure('secret-tool', `read ${key}`, result); return undefined; } return result.stdout.replace(/\n$/, ''); }, set(key, secret, label) { const result = run('secret-tool', ['store', `--label=${label}`, ...attributes(key)], secret); if (result.status !== 0) throw failure('secret-tool', `store ${key}`, result); }, delete(key) { run('secret-tool', ['clear', ...attributes(key)]); }, };}
// --------------------------------------------------------------- KWallet ---
function kwallet(): Keychain { const wallet = process.env['DAVSYNC_KWALLET'] || 'kdewallet'; return { name: 'kwallet', description: `KDE Wallet (${wallet})`, get(key) { const result = run('kwallet-query', ['-f', SERVICE, '-r', key, wallet]); if (result.status !== 0 || !result.stdout) return undefined; return result.stdout.replace(/\n$/, ''); }, set(key, secret) { const result = run('kwallet-query', ['-f', SERVICE, '-w', key, wallet], secret); if (result.status !== 0) throw failure('kwallet-query', `store ${key}`, result); }, delete(key) { // kwallet-query cannot remove an entry; an empty one reads as absent. run('kwallet-query', ['-f', SERVICE, '-w', key, wallet], ''); }, };}
// ---------------------------------------------------------------- macOS ---
function macos(): Keychain { // `security -i` reads commands from stdin, which keeps the secret off the // command line; quoting follows its own tokenizer, double quotes with // backslash escapes. const quote = (text: string) => `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; return { name: 'macos', description: 'your login keychain', get(key) { const result = run('security', ['find-generic-password', '-s', SERVICE, '-a', key, '-w']); // 44 is errSecItemNotFound. Anything else (a locked keychain answering a // background agent "user interaction is not allowed") is not an answer. if (result.status === 44) return undefined; if (result.status !== 0) throw failure('security', `read ${key}`, result); return result.stdout.replace(/\n$/, ''); }, set(key, secret, label) { const command = `add-generic-password -U -s ${quote(SERVICE)} -a ${quote(key)} -l ${quote(label)} -w ${quote(secret)}\n`; const result = run('security', ['-i'], command); if (result.status !== 0 || /error/i.test(result.stderr)) throw failure('security', `store ${key}`, result); }, delete(key) { run('security', ['delete-generic-password', '-s', SERVICE, '-a', key]); }, };}
// -------------------------------------------------------------- Windows ---
// Credential Manager through advapi32, from PowerShell: CredRead and// CredWrite on generic credentials named `davsync:<key>`. The script reads its// input as JSON from stdin.const WINDOWS_SCRIPT = String.raw`$ErrorActionPreference = 'Stop'Add-Type -TypeDefinition @'using System;using System.Runtime.InteropServices;public static class DavsyncCred { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct CREDENTIAL { public int Flags; public int Type; public string TargetName; public string Comment; public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist; public int AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName; } [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredRead(string target, int type, int flags, out IntPtr credential); [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredWrite(ref CREDENTIAL credential, int flags); [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredDelete(string target, int type, int flags); [DllImport("advapi32.dll")] public static extern void CredFree(IntPtr buffer); public static string Read(string target) { IntPtr pointer; if (!CredRead(target, 1, 0, out pointer)) return null; try { var credential = (CREDENTIAL)Marshal.PtrToStructure(pointer, typeof(CREDENTIAL)); return Marshal.PtrToStringUni(credential.CredentialBlob, credential.CredentialBlobSize / 2); } finally { CredFree(pointer); } } public static void Write(string target, string user, string secret) { var credential = new CREDENTIAL(); credential.Type = 1; credential.TargetName = target; credential.UserName = user; credential.Persist = 2; credential.CredentialBlobSize = secret.Length * 2; credential.CredentialBlob = Marshal.StringToCoTaskMemUni(secret); try { if (!CredWrite(ref credential, 0)) throw new System.ComponentModel.Win32Exception(); } finally { Marshal.FreeCoTaskMem(credential.CredentialBlob); } }}'@$request = [Console]::In.ReadToEnd() | ConvertFrom-Jsonswitch ($request.action) { 'get' { $value = [DavsyncCred]::Read($request.target); if ($null -eq $value) { exit 3 }; [Console]::Out.Write($value) } 'set' { [DavsyncCred]::Write($request.target, $request.label, $request.secret) } 'delete' { [void][DavsyncCred]::CredDelete($request.target, 1, 0) }}`;
function windows(): Keychain { const call = (request: Record<string, string>) => run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_SCRIPT], JSON.stringify(request)); return { name: 'windows', description: 'Windows Credential Manager', get(key) { const result = call({ action: 'get', target: `${SERVICE}:${key}` }); if (result.status === 3) return undefined; if (result.status !== 0) throw failure('PowerShell', `read ${key}`, result); return result.stdout; }, set(key, secret, label) { const result = call({ action: 'set', target: `${SERVICE}:${key}`, secret, label }); if (result.status !== 0) throw failure('PowerShell', `store ${key}`, result); }, delete(key) { call({ action: 'delete', target: `${SERVICE}:${key}` }); }, };}
// ----------------------------------------------------------------- file ---
function file(): Keychain { const home = OS.homedir(); const path = process.env['DAVSYNC_SECRETS'] ?? (process.platform === 'win32' ? PATH.join(process.env['LOCALAPPDATA'] || PATH.join(home, 'AppData', 'Local'), 'davsync', 'secrets.json') : PATH.join(process.env['XDG_DATA_HOME'] || PATH.join(home, '.local', 'share'), 'davsync', 'secrets.json')); const read = (): Record<string, string> => { try { return JSON.parse(FS.readFileSync(path, 'utf8')) as Record<string, string>; } catch { return {}; } }; const write = (secrets: Record<string, string>) => { FS.mkdirSync(PATH.dirname(path), { recursive: true, mode: 0o700 }); const temporary = `${path}.${process.pid}.tmp`; FS.writeFileSync(temporary, `${JSON.stringify(secrets, null, 2)}\n`, { mode: 0o600 }); FS.renameSync(temporary, path); }; return { name: 'file', description: `${path} (a file only you can read, not encrypted)`, get: (key) => read()[key], set(key, secret) { write({ ...read(), [key]: secret }); }, delete(key) { const secrets = read(); delete secrets[key]; write(secrets); }, };}