testsAndMisc/linux_configuration/scripts/single_use/features/keepass_sync.sh
Krzysztof kuhy Rudnicki 0902a5e1f4 linux_configuration: unified multi-device KeePass sync over dufs
Add "one vault, synced everywhere" for PC + Ubuntu laptop + phone, with an
identical flow and no stored master password. keepass-open prompts the master
password (in memory only), folds in strays, pulls+merges the cloud copy over
WebDAV, opens KeePassXC on ~/Keepass/Passwords.kdbx, and publishes edits on
close. Safety rests on `keepassxc-cli merge` (entry-level union) plus a dufs
?hash compare-and-swap on upload, so concurrent edits merge instead of clobber.

The consolidator enforces exactly one vault per machine: it folds a stray only
if it opens with the same master password, backs up first, never removes the
canonical, and auto-excludes the dufs serve-path (the remote store) — a
data-safety fix after the first real run tried to delete ~/cloud/Keepass.

Only the dufs *server* password (not the vault key) is cached in the keyring.
Single cross-distro installer, no role flag; the phone uses KeePassDX natively.

Verified: shellcheck clean; 8 (reconcile) + 14 (consolidator) + 8 (installer)
assertions on throwaway vaults; real PC apply with live WebDAV GET=200 and
?hash==sha256; a clean keepass-open run ("no strays / already in sync").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3
2026-07-12 05:00:27 +02:00

191 lines
7.5 KiB
Bash
Executable File

#!/bin/bash
# keepass_sync.sh — bidirectional, merge-safe reconcile of the KeePass vault
# between this device's working copy and the canonical cloud copy.
#
# The vault is a single encrypted file that is NOT multi-writer safe, so a raw
# copy/PUT would clobber concurrent edits. Safety comes from `keepassxc-cli
# merge` (entry-level, timestamp-based, idempotent). This script:
# 1. reads the master password (+ dufs creds) from the OS keyring (secret-tool)
# 2. downloads the remote copy
# 3. backs up the local copy, then merges remote INTO local (local = union)
# 4. uploads the merged local back to remote, guarded by a hash compare-and-swap
# so a concurrent remote change is never clobbered (it retries instead)
#
# It is self-contained (no repo lib deps) so it runs identically on the Arch PC
# and the Ubuntu work laptop. Triggered by keepass-sync.{path,timer}. Idempotent
# and flock-guarded — safe to run at any time, in parallel invocations queue.
#
# Config (non-secret) is read from $KP_SYNC_CONFIG (default ~/.config/keepass-sync/config.env):
# LOCAL_DB=/home/<you>/Keepass/Passwords.kdbx
# REMOTE_MODE=file|webdav
# REMOTE_FILE=/home/<you>/cloud/Keepass/Passwords.kdbx # file mode (the PC)
# REMOTE_URL=https://host/Keepass/Passwords.kdbx # webdav mode (laptop)
# DUFS_USER=<user> # webdav mode
# Secrets come from the keyring (service "keepass-sync"):
# key=master -> the vault master password
# key=dufs -> the dufs web password (webdav mode only)
set -euo pipefail
readonly KP_SYNC_CONFIG="${KP_SYNC_CONFIG:-$HOME/.config/keepass-sync/config.env}"
readonly KEYRING_SERVICE="${KP_KEYRING_SERVICE:-keepass-sync}"
readonly STATE_DIR="${KP_STATE_DIR:-$HOME/.config/keepass-sync}"
readonly LOCKFILE="$STATE_DIR/lock"
readonly MAX_CAS_RETRIES=3
log() { printf '[keepass-sync] %s\n' "$*" >&2; }
die() { printf '[keepass-sync] ERROR: %s\n' "$*" >&2; exit 1; }
# Clean, non-fatal skip (keyring locked / server down) — retried next cycle.
skip() { printf '[keepass-sync] skip: %s\n' "$*" >&2; exit 0; }
# --- load config -------------------------------------------------------------
[[ -f "$KP_SYNC_CONFIG" ]] || die "config not found: $KP_SYNC_CONFIG (run setup_keepass_sync.sh)"
# shellcheck source=/dev/null
source "$KP_SYNC_CONFIG"
: "${LOCAL_DB:?LOCAL_DB not set in config}"
: "${REMOTE_MODE:?REMOTE_MODE not set in config}"
TMPDIR_RUN=""
cleanup() { [[ -n "$TMPDIR_RUN" && -d "$TMPDIR_RUN" ]] && rm -rf "$TMPDIR_RUN"; }
trap cleanup EXIT
# --- secrets -----------------------------------------------------------------
# The vault master password is NEVER stored: the open-wrapper passes it in
# memory via KP_MASTER_PW for the duration of a KeePass session. (A keyring
# lookup is kept only as an optional fallback, off by default.)
MASTER_PW="${KP_MASTER_PW:-}"
if [[ -z "$MASTER_PW" ]]; then
MASTER_PW="$(secret-tool lookup service "$KEYRING_SERVICE" key master 2>/dev/null || true)"
fi
[[ -n "$MASTER_PW" ]] || skip "no master password provided (KP_MASTER_PW unset, keyring empty)"
# The dufs *server* password (webdav transport only) is NOT the vault key, so it
# may be cached in the keyring; env var overrides.
DUFS_PASS=""
if [[ "$REMOTE_MODE" == "webdav" ]]; then
: "${REMOTE_URL:?REMOTE_URL required for webdav mode}"
: "${DUFS_USER:?DUFS_USER required for webdav mode}"
DUFS_PASS="${KP_DUFS_PW:-$(secret-tool lookup service "$KEYRING_SERVICE" key dufs 2>/dev/null || true)}"
[[ -n "$DUFS_PASS" ]] || skip "no dufs password (KP_DUFS_PW unset, keyring empty)"
fi
sha() { sha256sum "$1" | cut -d' ' -f1; }
# --- remote transport abstraction (file mode vs webdav mode) -----------------
# remote_get <dest> -> 0 if fetched, 1 if remote does not exist yet
# remote_hash -> prints remote sha256, or empty if remote missing
# remote_put <src> -> upload src as the new remote (atomic where possible)
remote_get() {
local dest="$1"
case "$REMOTE_MODE" in
file)
[[ -f "$REMOTE_FILE" ]] || return 1
cp -f "$REMOTE_FILE" "$dest" ;;
webdav)
curl -fsS -u "$DUFS_USER:$DUFS_PASS" -o "$dest" "$REMOTE_URL" 2>/dev/null || return 1 ;;
*) die "unknown REMOTE_MODE: $REMOTE_MODE" ;;
esac
}
remote_hash() {
case "$REMOTE_MODE" in
file) [[ -f "$REMOTE_FILE" ]] && sha "$REMOTE_FILE" || true ;;
webdav) curl -fsS -u "$DUFS_USER:$DUFS_PASS" "${REMOTE_URL}?hash" 2>/dev/null || true ;;
esac
}
remote_put() {
local src="$1"
case "$REMOTE_MODE" in
file)
local dir; dir="$(dirname "$REMOTE_FILE")"
mkdir -p "$dir"
cp -f "$src" "$REMOTE_FILE.tmp.$$" && mv -f "$REMOTE_FILE.tmp.$$" "$REMOTE_FILE" ;;
webdav)
curl -fsS -u "$DUFS_USER:$DUFS_PASS" -T "$src" "$REMOTE_URL" -o /dev/null ;;
esac
}
# --- validate the master password against the local DB before touching it ----
verify_pw() {
printf '%s\n' "$MASTER_PW" | keepassxc-cli ls "$1" >/dev/null 2>&1
}
backup_local() {
local bdir
bdir="$(dirname "$LOCAL_DB")/.backup_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$bdir"
cp -f "$LOCAL_DB" "$bdir/" && log "backed up local vault to $bdir"
}
# --- the reconcile, with bounded compare-and-swap retries --------------------
reconcile() {
TMPDIR_RUN="$(mktemp -d)"
local tmp_remote="$TMPDIR_RUN/remote.kdbx"
# Bootstrap: if the local vault is missing, seed it from the remote.
if [[ ! -f "$LOCAL_DB" ]]; then
if remote_get "$tmp_remote"; then
mkdir -p "$(dirname "$LOCAL_DB")"
cp -f "$tmp_remote" "$LOCAL_DB"
log "bootstrapped local vault from remote"
return 0
fi
skip "no local vault and no remote vault yet — nothing to do"
fi
verify_pw "$LOCAL_DB" || die "master password from keyring does not open $LOCAL_DB — refusing to touch it"
local attempt=0
while (( attempt < MAX_CAS_RETRIES )); do
attempt=$((attempt + 1))
local remote_hash_before; remote_hash_before="$(remote_hash)"
if [[ -z "$remote_hash_before" ]]; then
# No remote yet — publish our local copy as the first version.
remote_put "$LOCAL_DB" && log "published local vault to remote (first copy)"
return 0
fi
local local_before; local_before="$(sha "$LOCAL_DB")"
if [[ "$remote_hash_before" == "$local_before" ]]; then
log "already in sync (hash $local_before)"
return 0
fi
remote_get "$tmp_remote" || skip "remote unreachable"
# Merge remote INTO local so local becomes the union. Back up first;
# never proceed to upload if the merge fails.
backup_local
if ! printf '%s\n' "$MASTER_PW" | keepassxc-cli merge --same-credentials "$LOCAL_DB" "$tmp_remote" >/dev/null 2>&1; then
die "keepassxc-cli merge failed — local vault left intact (see latest .backup_*)"
fi
local local_after; local_after="$(sha "$LOCAL_DB")"
# Compare-and-swap: only upload if the remote hasn't changed since our
# read; otherwise someone else wrote concurrently — retry the whole
# reconcile with the new remote (merges are commutative, so it converges).
local remote_hash_now; remote_hash_now="$(remote_hash)"
if [[ "$remote_hash_now" != "$remote_hash_before" ]]; then
log "remote changed during merge (CAS) — retrying ($attempt/$MAX_CAS_RETRIES)"
continue
fi
if [[ "$local_after" != "$remote_hash_now" ]]; then
remote_put "$LOCAL_DB" && log "uploaded merged vault to remote"
fi
log "reconcile complete (local $local_after)"
return 0
done
die "gave up after $MAX_CAS_RETRIES CAS retries — remote is changing too fast; will retry next cycle"
}
# --- serialize: only one reconcile at a time ---------------------------------
mkdir -p "$STATE_DIR"
exec 9>"$LOCKFILE"
if ! flock -n 9; then
skip "another reconcile is running"
fi
reconcile