mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-13 02:30:49 +01:00
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
This commit is contained in:
parent
36dd26b014
commit
0902a5e1f4
@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "One KeePass vault, synced across all devices",
|
||||
"objective": "Give every device (PC, Ubuntu laptop, phone) a single KeePass vault that stays in sync via the dufs cloud, with an identical open flow and without ever storing the vault master password. Success = editing on any device and, on the next open elsewhere, seeing a merged database with no lost entries and exactly one working vault per machine.",
|
||||
"acceptance_criteria": [
|
||||
"keepass-open prompts the master password (held only in memory), folds in strays, pulls+merges the cloud copy, opens KeePassXC on ~/Keepass/Passwords.kdbx, and publishes edits on close.",
|
||||
"keepassxc-cli merge is used so concurrent edits merge (union) rather than clobber; uploads are guarded by a dufs ?hash compare-and-swap.",
|
||||
"The consolidator folds a stray only if it opens with the same master password, backs up first, never removes the canonical, and NEVER touches the dufs serve-path (the remote store).",
|
||||
"Only the dufs server password (not the vault key) is cached in the keyring; no secret is written to the repo.",
|
||||
"Same unified installer + open command on PC and laptop (no role flag)."
|
||||
],
|
||||
"out_of_scope": [
|
||||
"Android app changes (the phone uses KeePassDX natively)",
|
||||
"A background/continuous sync daemon (sync rides on open/close by design)",
|
||||
"Phases 2-4 (downloads-to-cloud, web gallery, mobile app)"
|
||||
],
|
||||
"verifier": "shellcheck -x + bash -n on all four scripts; keepass_sync.sh (8) + keepass_consolidate.sh incl. serve-path exclusion (14) + setup_keepass_sync.sh unified (8) assertion suites on throwaway vaults; real PC apply with live WebDAV GET=200 and ?hash==sha256; documented in docs/superpowers/evidence/keepass-multidevice-sync-2026-07-12.json"
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
{
|
||||
"intent": "One KeePass vault, synced across PC + Ubuntu laptop + phone with an IDENTICAL flow on every device: you open the vault with a single `keepass-open` command that pulls+merges the latest, opens KeePassXC on ~/Keepass/Passwords.kdbx, and publishes your edits on close. The vault master password is never stored — it is typed at open and held only in memory. Exactly one working vault per machine; strays are folded in.",
|
||||
"scope": [
|
||||
"linux_configuration/scripts/single_use/features/keepass_sync.sh (bidirectional merge-safe reconcile over WebDAV or local file)",
|
||||
"linux_configuration/scripts/single_use/features/keepass_consolidate.sh (fold stray *.kdbx into the one vault)",
|
||||
"linux_configuration/scripts/single_use/features/keepass-open.sh (the one open command: prompt → consolidate → pull+merge → launch → push)",
|
||||
"linux_configuration/scripts/single_use/features/setup_keepass_sync.sh (single unified installer — no pc/laptop flag)",
|
||||
"Non-goals: no stored master password, no background daemon, no systemd units; the phone uses KeePassDX (same workflow, different app)"
|
||||
],
|
||||
"changes": [
|
||||
"Unified model: every Linux device keeps a local ~/Keepass/Passwords.kdbx and syncs it to the canonical copy on the dufs server over WebDAV — the PC included (it talks to its own dufs over the public URL). One command, no role flag; the installer caches only the dufs *server* password in the keyring and bootstraps the local vault via an authenticated WebDAV GET.",
|
||||
"Reconcile engine (keepass_sync.sh): master password from KP_MASTER_PW (in-memory, never on disk); download remote, back up local, `keepassxc-cli merge --same-credentials` so local becomes the union, then upload guarded by a dufs ?hash compare-and-swap so a concurrent remote change is retried, never clobbered. flock-guarded, idempotent, self-contained (Arch + Ubuntu).",
|
||||
"Consolidator: `fd`-sweeps for strays; a stray is merged+removed ONLY if it opens with the same master password (different-password DBs are reported and left untouched); canonical never removed; both backed up first; --dry-run supported.",
|
||||
"Fixed a pipefail bug: unit detection used `systemctl list-unit-files | grep -q`, which reports failure under set -o pipefail (grep -q closes the pipe → systemctl SIGPIPE) even on a match; switched to pipe-free `systemctl is-enabled/is-active`.",
|
||||
"Fixed a data-safety bug found on the first real run: the consolidator treated the dufs serve-path (~/cloud/Keepass, i.e. the REMOTE store) as a stray and removed it every open (the sync re-published it, so no data was lost, but it was wrong/churny). It now auto-excludes the dufs serve-path (parsed from ~/.config/dufs/dufs.yaml) plus any --exclude/KP_EXCLUDE dirs."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "shellcheck -x --severity=warning + bash -n on all four scripts",
|
||||
"result": "pass",
|
||||
"evidence": "all four scripts syntax-OK and shellcheck-clean."
|
||||
},
|
||||
{
|
||||
"command": "keepass_sync.sh end-to-end (real keepassxc-cli/secret-tool/dufs, throwaway DBs)",
|
||||
"result": "pass",
|
||||
"evidence": "8/8: file- and webdav-mode divergent vaults converge (both entries, identical after); idempotent re-run reports 'already in sync'; bootstrap seeds a missing local; a WRONG password refuses to touch the DB."
|
||||
},
|
||||
{
|
||||
"command": "keepass_consolidate.sh safety tests (throwaway dirs)",
|
||||
"result": "pass",
|
||||
"evidence": "10/10: same-password stray merged+removed; DIFFERENT-password stray NOT deleted and intact; canonical never deleted; backup-dir kdbx ignored; backups created; --dry-run inert."
|
||||
},
|
||||
{
|
||||
"command": "setup_keepass_sync.sh unified, against a scratchpad dufs (fake HOME, throwaway keyring, stubbed sudo/systemctl)",
|
||||
"result": "pass",
|
||||
"evidence": "8/8: writes webdav config with LOCAL_DB=~/Keepass/Passwords.kdbx; caches the dufs password in the keyring; bootstraps the local vault via WebDAV GET (opens + has the server entry); installs keepass-open + keepass-consolidate; idempotent re-run detects the existing vault."
|
||||
},
|
||||
{
|
||||
"command": "dufs serve-path exclusion (the data-safety fix), throwaway dirs + real dufs.yaml parse",
|
||||
"result": "pass",
|
||||
"evidence": "4/4: with a dufs.yaml serve-path, the served store under it is NOT deleted and stays intact while a genuine stray elsewhere is still folded in and removed; real ~/.config/dufs/dufs.yaml parses to /home/kuhy/cloud and ~/cloud/Keepass is correctly matched as excluded."
|
||||
},
|
||||
{
|
||||
"command": "Real PC apply + live transport",
|
||||
"result": "pass",
|
||||
"evidence": "setup_keepass_sync.sh ran on the PC: deps ok, dufs password cached, config written, ~/Keepass bootstrapped, old one-way unit disabled+inactive, both commands installed. Live WebDAV GET=200 and remote ?hash == local sha256. keepass-open opened KeePassXC and the close-push uploaded (both files identical, throwaway entry preserved). Data confirmed safe after the consolidator bug."
|
||||
},
|
||||
{
|
||||
"command": "Laptop apply + phone KeePassDX round-trip",
|
||||
"result": "pending",
|
||||
"evidence": "Laptop is a manual user run of the same installer; phone round-trip deferred (phone in night-mode curfew). To confirm when available."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"Master password is prompted each open and lives only in process memory; only the dufs *server* password (not the vault key) is cached in the keyring.",
|
||||
"Concurrent WebDAV writes are made safe by the ?hash compare-and-swap + commutative keepassxc-cli merge (retries, never clobbers); a closed device picks up others' edits at its next open.",
|
||||
"The PC's earlier direct-served-file setup is replaced: the installer re-bootstraps ~/Keepass/Passwords.kdbx and you open via keepass-open on every device (backups retained under ~/Keepass/.backup_*)."
|
||||
],
|
||||
"rollback": [
|
||||
"rm ~/.config/keepass-sync/ ~/.local/bin/keepass-open ~/.local/bin/keepass-consolidate; secret-tool clear service keepass-sync key dufs.",
|
||||
"Re-enable the old one-way sync if desired: sudo systemctl enable --now keepass-cloud-sync.path.",
|
||||
"Restore any vault from the timestamped ~/Keepass/.backup_* directories."
|
||||
]
|
||||
}
|
||||
59
linux_configuration/scripts/single_use/features/keepass-open.sh
Executable file
59
linux_configuration/scripts/single_use/features/keepass-open.sh
Executable file
@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# keepass-open.sh — open the KeePass vault with sync, WITHOUT storing the master
|
||||
# password anywhere. This is the laptop/remote-device entry point: you run this
|
||||
# instead of clicking KeePassXC directly.
|
||||
#
|
||||
# Flow (the master password lives only in this process's memory):
|
||||
# 1. prompt for the vault master password
|
||||
# 2. consolidate any stray *.kdbx on this device into the one canonical vault
|
||||
# 3. reconcile with the cloud (pull remote + merge + push local) — bidirectional
|
||||
# 4. launch KeePassXC on the local vault and wait
|
||||
# 5. on close, reconcile once more so this session's edits are published
|
||||
#
|
||||
# The dufs *server* password (transport only, not the vault key) comes from the
|
||||
# keyring. Config is read from ~/.config/keepass-sync/config.env (written by
|
||||
# setup_keepass_sync.sh). Self-contained; runs on Arch or Ubuntu.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
readonly SYNC="$HERE/keepass_sync.sh"
|
||||
readonly CONSOLIDATE="$HERE/keepass_consolidate.sh"
|
||||
readonly CONFIG="${KP_SYNC_CONFIG:-$HOME/.config/keepass-sync/config.env}"
|
||||
|
||||
log() { printf '\033[1;34m[keepass-open]\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[1;31m[keepass-open] ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[[ -f "$CONFIG" ]] || die "config $CONFIG missing — run setup_keepass_sync.sh first"
|
||||
# shellcheck source=/dev/null
|
||||
source "$CONFIG"
|
||||
: "${LOCAL_DB:?LOCAL_DB not set in config}"
|
||||
command -v keepassxc >/dev/null || die "keepassxc (GUI) not found"
|
||||
|
||||
# If KeePassXC is already running, our "wait then push on close" won't work
|
||||
# (the new invocation just focuses the existing window and returns immediately).
|
||||
if pgrep -x keepassxc >/dev/null 2>&1; then
|
||||
WARN() { printf '\033[1;33m[keepass-open] !\033[0m %s\n' "$*"; }
|
||||
WARN "KeePassXC is already running — close it first so edits publish on exit."
|
||||
fi
|
||||
|
||||
# Prompt for the master password (never echoed, never written to disk).
|
||||
read -r -s -p "KeePass master password: " KP_MASTER_PW; echo
|
||||
[[ -n "$KP_MASTER_PW" ]] || die "no password entered"
|
||||
export KP_MASTER_PW
|
||||
|
||||
# 2 + 3: consolidate strays, then reconcile with the cloud (pull+merge+push).
|
||||
log "consolidating strays and syncing with the cloud…"
|
||||
KP_CANONICAL="$LOCAL_DB" bash "$CONSOLIDATE" --canonical "$LOCAL_DB" || log "consolidation reported an issue (continuing)"
|
||||
bash "$SYNC" || die "initial sync failed — NOT opening a possibly-stale vault"
|
||||
|
||||
# 4: open KeePassXC and wait until it is closed.
|
||||
log "opening KeePassXC…"
|
||||
keepassxc "$LOCAL_DB" || true
|
||||
|
||||
# 5: publish this session's edits.
|
||||
log "closing — publishing your changes…"
|
||||
bash "$SYNC" || log "final sync failed; changes will publish on next open"
|
||||
|
||||
unset KP_MASTER_PW
|
||||
log "done."
|
||||
135
linux_configuration/scripts/single_use/features/keepass_consolidate.sh
Executable file
135
linux_configuration/scripts/single_use/features/keepass_consolidate.sh
Executable file
@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# keepass_consolidate.sh — enforce "exactly one KeePass vault" on this device.
|
||||
#
|
||||
# Finds stray *.kdbx files scattered around your folders, merges each into the
|
||||
# ONE canonical vault, and removes the stray — so you never again wonder which
|
||||
# copy is current. It is deliberately conservative:
|
||||
# * a stray is only touched if it opens with the SAME master password
|
||||
# (different-password DBs are reported and left completely alone);
|
||||
# * the canonical vault AND every stray are backed up before any change;
|
||||
# * a stray is removed ONLY after its merge into the canonical succeeded.
|
||||
#
|
||||
# The master password is taken from KP_MASTER_PW (in memory, never stored) —
|
||||
# normally supplied by keepass-open.sh, which already has it. Self-contained so
|
||||
# it runs on both the Arch PC and the Ubuntu laptop.
|
||||
#
|
||||
# Usage: KP_MASTER_PW=… keepass_consolidate.sh [--canonical PATH] [--root DIR] [--dry-run]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CANONICAL="${KP_CANONICAL:-$HOME/cloud/Keepass/Passwords.kdbx}"
|
||||
SEARCH_ROOT="${KP_SEARCH_ROOT:-$HOME}"
|
||||
DRY_RUN=0
|
||||
STATE_DIR="${KP_STATE_DIR:-$HOME/.config/keepass-sync}"
|
||||
|
||||
EXCLUDE_ARGS=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--canonical) CANONICAL="$2"; shift 2 ;;
|
||||
--root) SEARCH_ROOT="$2"; shift 2 ;;
|
||||
--exclude) EXCLUDE_ARGS+=("$2"); shift 2 ;;
|
||||
--dry-run|-n) DRY_RUN=1; shift ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { printf '[keepass-consolidate] %s\n' "$*" >&2; }
|
||||
die() { printf '[keepass-consolidate] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
MASTER_PW="${KP_MASTER_PW:-}"
|
||||
[[ -n "$MASTER_PW" ]] || die "KP_MASTER_PW not set (needed to open/merge vaults)"
|
||||
command -v keepassxc-cli >/dev/null || die "keepassxc-cli not found"
|
||||
# fd is 'fd' on Arch, 'fdfind' on Debian/Ubuntu.
|
||||
FD="$(command -v fd || command -v fdfind || true)"
|
||||
[[ -n "$FD" ]] || die "fd (fd-find) not found"
|
||||
|
||||
[[ -f "$CANONICAL" ]] || die "canonical vault not found: $CANONICAL"
|
||||
opens() { printf '%s\n' "$MASTER_PW" | keepassxc-cli ls "$1" >/dev/null 2>&1; }
|
||||
opens "$CANONICAL" || die "master password does not open the canonical vault — aborting"
|
||||
|
||||
backup() { # backup <file> — copy into a timestamped sibling .backup_ dir
|
||||
local f="$1" bdir
|
||||
bdir="$(dirname "$f")/.backup_$(date +%Y%m%d_%H%M%S)_consolidate"
|
||||
mkdir -p "$bdir"; cp -f "$f" "$bdir/"
|
||||
}
|
||||
|
||||
# --- exclude prefixes: dirs whose vaults are NOT strays -----------------------
|
||||
# Critically the dufs serve-path: on the machine running dufs that directory IS
|
||||
# the remote canonical store (e.g. ~/cloud/Keepass) — it must never be treated
|
||||
# as a stray and removed. Plus any --exclude args and KP_EXCLUDE (colon-list).
|
||||
EXCLUDES=()
|
||||
_add_exclude() { local p; p="$(readlink -f "$1" 2>/dev/null || echo "$1")"; [[ -n "$p" ]] && EXCLUDES+=("$p"); }
|
||||
_dufs_cfg="$HOME/.config/dufs/dufs.yaml"
|
||||
if [[ -f "$_dufs_cfg" ]]; then
|
||||
_sp="$(sed -nE 's/^serve-path:[[:space:]]*//p' "$_dufs_cfg" | head -1)"
|
||||
[[ -n "$_sp" ]] && _add_exclude "$_sp"
|
||||
fi
|
||||
IFS=':' read -r -a _kp_excl <<<"${KP_EXCLUDE:-}"
|
||||
for e in "${_kp_excl[@]}" "${EXCLUDE_ARGS[@]}"; do [[ -n "$e" ]] && _add_exclude "$e"; done
|
||||
|
||||
is_excluded() { # is_excluded <abs-path> — under any excluded prefix?
|
||||
local p="$1" ex
|
||||
for ex in "${EXCLUDES[@]}"; do
|
||||
[[ "$p" == "$ex" || "$p" == "$ex"/* ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
[[ ${#EXCLUDES[@]} -gt 0 ]] && log "excluding: ${EXCLUDES[*]}"
|
||||
|
||||
# --- locate strays -----------------------------------------------------------
|
||||
# Absolute canonical path, so we can exclude it exactly.
|
||||
CANON_ABS="$(readlink -f "$CANONICAL")"
|
||||
mapfile -t CANDIDATES < <(
|
||||
"$FD" -e kdbx -u -a --exclude '.backup_*' --exclude 'node_modules' \
|
||||
--exclude '.git' --exclude '.cache' --exclude 'Trash' \
|
||||
--exclude 'snap' --exclude '.local/share/Trash' \
|
||||
. "$SEARCH_ROOT" 2>/dev/null || true
|
||||
)
|
||||
|
||||
STRAYS=()
|
||||
for f in "${CANDIDATES[@]}"; do
|
||||
[[ -f "$f" ]] || continue
|
||||
local_abs="$(readlink -f "$f")"
|
||||
[[ "$local_abs" == "$CANON_ABS" ]] && continue # the canonical itself
|
||||
is_excluded "$local_abs" && continue # dufs store / excluded dirs
|
||||
# Skip our own timestamped backups defensively (in case fd glob missed).
|
||||
[[ "$f" == *"/.backup_"* ]] && continue
|
||||
STRAYS+=("$f")
|
||||
done
|
||||
|
||||
if [[ ${#STRAYS[@]} -eq 0 ]]; then
|
||||
log "no strays found — exactly one vault ($CANONICAL). Done."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "found ${#STRAYS[@]} candidate stray vault(s) under $SEARCH_ROOT"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
exec 9>"$STATE_DIR/consolidate.lock"
|
||||
flock -n 9 || die "another consolidation is running"
|
||||
|
||||
merged=0 skipped=0
|
||||
for stray in "${STRAYS[@]}"; do
|
||||
if ! opens "$stray"; then
|
||||
log "SKIP (different master password, left untouched): $stray"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
log "[dry-run] would merge + remove: $stray"
|
||||
merged=$((merged + 1))
|
||||
continue
|
||||
fi
|
||||
backup "$CANONICAL"
|
||||
backup "$stray"
|
||||
if printf '%s\n' "$MASTER_PW" | keepassxc-cli merge --same-credentials "$CANONICAL" "$stray" >/dev/null 2>&1; then
|
||||
rm -f "$stray"
|
||||
log "merged + removed stray: $stray"
|
||||
merged=$((merged + 1))
|
||||
else
|
||||
log "merge FAILED (stray kept): $stray"
|
||||
skipped=$((skipped + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
log "done: $merged merged, $skipped skipped. Canonical: $CANONICAL"
|
||||
190
linux_configuration/scripts/single_use/features/keepass_sync.sh
Executable file
190
linux_configuration/scripts/single_use/features/keepass_sync.sh
Executable file
@ -0,0 +1,190 @@
|
||||
#!/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
|
||||
128
linux_configuration/scripts/single_use/features/setup_keepass_sync.sh
Executable file
128
linux_configuration/scripts/single_use/features/setup_keepass_sync.sh
Executable file
@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
# setup_keepass_sync.sh — set up "one KeePass vault, synced everywhere".
|
||||
#
|
||||
# IDENTICAL on every Linux device (PC and laptop alike): each keeps one local
|
||||
# working vault at ~/Keepass/Passwords.kdbx and syncs it to the canonical copy
|
||||
# on the dufs server over WebDAV, at the moment you open KeePass. You open the
|
||||
# vault with a single command — `keepass-open` — on every machine. The phone
|
||||
# uses KeePassDX (same idea: enter password → Synchronize over WebDAV → use).
|
||||
#
|
||||
# The vault MASTER password is never stored — you type it into keepass-open,
|
||||
# which holds it only in memory for that session. Only the dufs *server*
|
||||
# password (transport, not the vault key) is cached in the keyring.
|
||||
#
|
||||
# Cross-distro (pacman / apt). Run as your normal user; sudo is used only for
|
||||
# package install and to retire the old one-way mirror unit if present.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
readonly HERE
|
||||
readonly CONFIG_DIR="$HOME/.config/keepass-sync"
|
||||
readonly CONFIG="$CONFIG_DIR/config.env"
|
||||
readonly BIN_DIR="$HOME/.local/bin"
|
||||
readonly KEYRING_SERVICE="${KP_KEYRING_SERVICE:-keepass-sync}"
|
||||
readonly LOCAL_DB="$HOME/Keepass/Passwords.kdbx"
|
||||
|
||||
C() { printf '\033[1;34m[keepass-setup]\033[0m %s\n' "$*"; }
|
||||
OK() { printf '\033[1;32m ✓\033[0m %s\n' "$*"; }
|
||||
WARN() { printf '\033[1;33m !\033[0m %s\n' "$*"; }
|
||||
die() { printf '\033[1;31m[keepass-setup] ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
usage() { grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'; exit 0; }
|
||||
|
||||
install_deps() {
|
||||
C "Installing dependencies"
|
||||
if command -v pacman >/dev/null; then
|
||||
sudo pacman -S --needed --noconfirm keepassxc fd curl inotify-tools libsecret
|
||||
elif command -v apt-get >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y keepassxc fd-find curl inotify-tools libsecret-tools
|
||||
else
|
||||
die "no supported package manager (pacman/apt) found"
|
||||
fi
|
||||
command -v keepassxc-cli >/dev/null || die "keepassxc-cli missing after install"
|
||||
command -v secret-tool >/dev/null || die "secret-tool (libsecret) missing after install"
|
||||
OK "dependencies present"
|
||||
}
|
||||
|
||||
main() {
|
||||
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && usage
|
||||
C "Setting up unified KeePass sync (same on every device)"
|
||||
install_deps
|
||||
|
||||
# --- config: dufs WebDAV endpoint + server credential -------------------
|
||||
local url user dpass
|
||||
read -r -p "dufs vault URL [https://kuhy-cloud.duckdns.org/Keepass/Passwords.kdbx]: " url
|
||||
url="${url:-https://kuhy-cloud.duckdns.org/Keepass/Passwords.kdbx}"
|
||||
read -r -p "dufs web username: " user
|
||||
read -r -s -p "dufs web password (server credential — cached in keyring): " dpass; echo
|
||||
[[ -n "$user" && -n "$dpass" ]] || die "dufs username/password required"
|
||||
printf '%s' "$dpass" | secret-tool store --label='dufs server password (keepass-sync)' \
|
||||
service "$KEYRING_SERVICE" key dufs
|
||||
OK "cached dufs server password in the keyring"
|
||||
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
{
|
||||
echo "LOCAL_DB=$LOCAL_DB"
|
||||
echo "REMOTE_MODE=webdav"
|
||||
echo "REMOTE_URL=$url"
|
||||
echo "DUFS_USER=$user"
|
||||
} >"$CONFIG"
|
||||
chmod 600 "$CONFIG"
|
||||
OK "wrote $CONFIG"
|
||||
|
||||
# --- bootstrap the local working vault if this device doesn't have one ---
|
||||
if [[ ! -f "$LOCAL_DB" ]]; then
|
||||
mkdir -p "$(dirname "$LOCAL_DB")"
|
||||
if curl -fsS -u "$user:$dpass" -o "$LOCAL_DB" "$url" 2>/dev/null; then
|
||||
OK "bootstrapped $LOCAL_DB from the cloud"
|
||||
else
|
||||
WARN "no vault on the server yet — a local one will be published on first open"
|
||||
fi
|
||||
else
|
||||
OK "local vault already present: $LOCAL_DB"
|
||||
fi
|
||||
|
||||
# --- retire the old one-way mirror unit (dufs host only) ----------------
|
||||
# is-enabled/is-active are pipe-free and work as non-root (unlike
|
||||
# `list-unit-files | grep -q`, which misfires under set -o pipefail).
|
||||
if systemctl is-enabled keepass-cloud-sync.path >/dev/null 2>&1 \
|
||||
|| systemctl is-active keepass-cloud-sync.path >/dev/null 2>&1; then
|
||||
C "Retiring the old one-way keepass-cloud-sync units"
|
||||
sudo systemctl disable --now keepass-cloud-sync.path keepass-cloud-sync.service 2>/dev/null || true
|
||||
OK "old one-way sync retired"
|
||||
fi
|
||||
|
||||
# --- install the two commands -------------------------------------------
|
||||
mkdir -p "$BIN_DIR"
|
||||
ln -sf "$HERE/keepass-open.sh" "$BIN_DIR/keepass-open"
|
||||
OK "installed keepass-open → $BIN_DIR/keepass-open"
|
||||
local cons="$BIN_DIR/keepass-consolidate"
|
||||
cat >"$cons" <<EOF
|
||||
#!/bin/bash
|
||||
# Prompt for the master password and fold stray vaults into the canonical one.
|
||||
set -euo pipefail
|
||||
read -r -s -p "KeePass master password: " KP_MASTER_PW; echo
|
||||
export KP_MASTER_PW
|
||||
exec "$HERE/keepass_consolidate.sh" --canonical "$LOCAL_DB" "\$@"
|
||||
EOF
|
||||
chmod +x "$cons"
|
||||
OK "installed keepass-consolidate → $cons"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
Done. Same on every device. Open your vault with: keepass-open
|
||||
It: asks your master password (never stored) → folds in strays → pulls+merges
|
||||
the latest → opens KeePassXC on $LOCAL_DB → publishes your edits on close.
|
||||
|
||||
• Make sure $BIN_DIR is on your PATH.
|
||||
• Always open via 'keepass-open' (not the raw KeePassXC icon) so it syncs.
|
||||
• Phone: KeePassDX → open the database from URL (WebDAV):
|
||||
$url
|
||||
then use its Synchronize action after editing.
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
EOF
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Loading…
Reference in New Issue
Block a user