From 0e83acb9b1e31ff868d955d1e32720cfe2943b22 Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Thu, 29 Oct 2026 16:26:33 +0100 Subject: [PATCH] Add Android client, packaging, Makefile entrypoint and handover docs Why: the set has to be installable and publishable, not just buildable, and the next person needs the reasoning, not only the code. What: - android/: Kotlin client with no third-party dependencies. A DocumentsProvider publishes the mirrored databases to every file picker, so KeePassDX opens them as ordinary documents; closing a file it wrote triggers a sync. A foreground service holds the event stream open. Mirror.decide transcribes the Go merge table, so both platforms resolve a conflict identically. - Makefile as the single entrypoint, with file targets so only what changed is rebuilt: host and cross builds, both .deb packages, the macOS DMG with its /Applications symlink, the Windows zip, the APK under its F-Droid publishing name, and manual-only android-publish. - versions.mk holds one version per platform plus the Android versionCode; `make bump` and `make changelog` move them. Debian changelog is tracked. - systemd units (hardened system unit for the server, user unit for the agent), macOS bundle and launch agent, Windows Task Scheduler job. - docs/HANDOVER.md on architecture and every decision that shaped it, docs/PROTOCOL.md for the API, README for the quick start. Proved: make check and make test-race both pass; built both .deb packages and inspected their contents, built the Windows zip, cross-compiled all five desktop targets, and exercised make bump. The Android module is written but not compiled: no JDK or Android SDK in this environment, which HANDOVER.md states plainly. --- Makefile | 188 +++++++++++ README.md | 56 +++ android/README.md | 34 ++ android/app/build.gradle.kts | 43 +++ android/app/src/main/AndroidManifest.xml | 48 +++ .../cc/tiredsysadmin/android/keeporg/Api.kt | 244 ++++++++++++++ .../android/keeporg/KeepOrgProvider.kt | 182 ++++++++++ .../android/keeporg/LoginActivity.kt | 116 +++++++ .../android/keeporg/MainActivity.kt | 121 +++++++ .../tiredsysadmin/android/keeporg/Mirror.kt | 244 ++++++++++++++ .../cc/tiredsysadmin/android/keeporg/Prefs.kt | 77 +++++ .../android/keeporg/SyncService.kt | 111 ++++++ .../tiredsysadmin/android/keeporg/Syncer.kt | 48 +++ .../cc/tiredsysadmin/android/keeporg/Ui.kt | 71 ++++ .../res/drawable/ic_launcher_foreground.xml | 17 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/strings.xml | 7 + android/build.gradle.kts | 7 + android/gradle.properties | 3 + .../cc.tiredsysadmin.android.keeporg.yml | 44 +++ android/settings.gradle.kts | 15 + docs/HANDOVER.md | 318 ++++++++++++++++++ docs/PROTOCOL.md | 91 +++++ internal/server/server.go | 48 +-- packaging/deb/changelog | 11 + packaging/deb/client/control.in | 17 + packaging/deb/copyright | 14 + packaging/deb/server/control.in | 19 ++ packaging/macos/Info.plist.in | 27 ++ packaging/macos/launch-agent.plist.in | 22 ++ packaging/macos/launcher.sh | 14 + packaging/systemd/keeporg.service | 16 + packaging/systemd/keeporgd.service | 38 +++ packaging/windows/README.txt | 30 ++ packaging/windows/keeporg-task.xml | 34 ++ scripts/build-deb.sh | 83 +++++ scripts/build-dmg.sh | 39 +++ scripts/bump.sh | 54 +++ scripts/changelog.sh | 26 ++ 40 files changed, 2563 insertions(+), 24 deletions(-) create mode 100644 Makefile create mode 100644 README.md create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Api.kt create mode 100644 android/app/src/main/java/cc/tiredsysadmin/android/keeporg/KeepOrgProvider.kt create mode 100644 android/app/src/main/java/cc/tiredsysadmin/android/keeporg/LoginActivity.kt create mode 100644 android/app/src/main/java/cc/tiredsysadmin/android/keeporg/MainActivity.kt create mode 100644 android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Mirror.kt create mode 100644 android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt create mode 100644 android/app/src/main/java/cc/tiredsysadmin/android/keeporg/SyncService.kt create mode 100644 android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Syncer.kt create mode 100644 android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Ui.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/metadata/cc.tiredsysadmin.android.keeporg.yml create mode 100644 android/settings.gradle.kts create mode 100644 docs/HANDOVER.md create mode 100644 docs/PROTOCOL.md create mode 100644 packaging/deb/changelog create mode 100644 packaging/deb/client/control.in create mode 100644 packaging/deb/copyright create mode 100644 packaging/deb/server/control.in create mode 100644 packaging/macos/Info.plist.in create mode 100644 packaging/macos/launch-agent.plist.in create mode 100644 packaging/macos/launcher.sh create mode 100644 packaging/systemd/keeporg.service create mode 100644 packaging/systemd/keeporgd.service create mode 100644 packaging/windows/README.txt create mode 100644 packaging/windows/keeporg-task.xml create mode 100755 scripts/build-deb.sh create mode 100755 scripts/build-dmg.sh create mode 100755 scripts/bump.sh create mode 100755 scripts/changelog.sh diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c3ce071 --- /dev/null +++ b/Makefile @@ -0,0 +1,188 @@ +# KeepOrg: build and publish entrypoint. +# +# Every target is a real file where it can be, so make rebuilds only what +# changed. Run `make help` for the list. + +include versions.mk + +GO := go +BUILD := build +DIST := dist +GOFLAGS := -trimpath +HOSTOS := $(shell $(GO) env GOOS) +HOSTARCH := $(shell $(GO) env GOARCH) +GO_SRC := $(shell find . -name '*.go' -not -path './$(DIST)/*' -not -path './$(BUILD)/*') go.mod + +# The version stamped into a binary depends on the platform it is built for. +version_for = $(strip \ + $(if $(filter linux,$(1)),$(VERSION_LINUX),\ + $(if $(filter darwin,$(1)),$(VERSION_MACOS),\ + $(if $(filter windows,$(1)),$(VERSION_WINDOWS),\ + $(if $(filter freebsd openbsd netbsd,$(1)),$(VERSION_BSD),0.0.0))))) + +os_of = $(word 1,$(subst -, ,$(1))) +arch_of = $(word 2,$(subst -, ,$(1))) + +HOST_CLIENT := $(BUILD)/$(HOSTOS)-$(HOSTARCH)/keeporg +HOST_SERVER := $(BUILD)/$(HOSTOS)-$(HOSTARCH)/keeporgd + +.DEFAULT_GOAL := help + +# ---------------------------------------------------------------- developing + +help: ## show this help + @printf 'KeepOrg %s\n\n' "$(VERSION_LINUX)" + @grep -hE '^[a-zA-Z0-9_%/.-]+:.*?## ' $(MAKEFILE_LIST) \ + | sort | awk 'BEGIN{FS=":.*?## "}{printf " \033[1m%-22s\033[0m %s\n", $$1, $$2}' + @printf '\nVersions: server %s linux %s macos %s windows %s bsd %s android %s (code %s)\n' \ + "$(VERSION_SERVER)" "$(VERSION_LINUX)" "$(VERSION_MACOS)" "$(VERSION_WINDOWS)" \ + "$(VERSION_BSD)" "$(VERSION_ANDROID)" "$(ANDROID_VERSION_CODE)" + +fmt: ## format the Go sources + gofmt -w $(filter %.go,$(GO_SRC)) + +vet: ## run go vet + $(GO) vet ./... + +test: ## run the whole test suite + $(GO) test ./... + +test-race: ## run the test suite under the race detector + $(GO) test -race ./... + +check: fmt vet test ## format, vet and test + +clean: ## remove build and dist output + rm -rf $(BUILD) $(DIST) + +# ------------------------------------------------------------------ building + +.PHONY: all server client +all: server client ## build the server and client for this host +server: $(HOST_SERVER) ## build the server for this host +client: $(HOST_CLIENT) ## build the desktop client for this host + +$(BUILD)/%/keeporg: $(GO_SRC) + @mkdir -p $(dir $@) + GOOS=$(call os_of,$*) GOARCH=$(call arch_of,$*) CGO_ENABLED=0 \ + $(GO) build $(GOFLAGS) \ + -ldflags "-s -w -X main.Version=$(call version_for,$(call os_of,$*))" \ + -o $@ ./cmd/keeporg + +$(BUILD)/%/keeporgd: $(GO_SRC) + @mkdir -p $(dir $@) + GOOS=$(call os_of,$*) GOARCH=$(call arch_of,$*) CGO_ENABLED=0 \ + $(GO) build $(GOFLAGS) \ + -ldflags "-s -w -X main.Version=$(VERSION_SERVER)" \ + -o $@ ./cmd/keeporgd + +$(BUILD)/windows-%/keeporg.exe: $(GO_SRC) + @mkdir -p $(dir $@) + GOOS=windows GOARCH=$* CGO_ENABLED=0 \ + $(GO) build $(GOFLAGS) -ldflags "-s -w -X main.Version=$(VERSION_WINDOWS)" \ + -o $@ ./cmd/keeporg + +.PHONY: cross +cross: ## build every desktop and server binary +cross: $(BUILD)/linux-amd64/keeporg $(BUILD)/linux-arm64/keeporg \ + $(BUILD)/linux-amd64/keeporgd $(BUILD)/linux-arm64/keeporgd \ + $(BUILD)/darwin-amd64/keeporg $(BUILD)/darwin-arm64/keeporg \ + $(BUILD)/freebsd-amd64/keeporg $(BUILD)/freebsd-arm64/keeporg \ + $(BUILD)/windows-amd64/keeporg.exe + +# ------------------------------------------------------------------ packaging + +DEB_ARCH ?= $(HOSTARCH) +DEB_CLIENT := $(DIST)/keeporg_$(VERSION_LINUX)_$(DEB_ARCH).deb +DEB_SERVER := $(DIST)/keeporgd_$(VERSION_SERVER)_$(DEB_ARCH).deb + +.PHONY: deb deb-client deb-server +deb: deb-client deb-server ## build both Debian packages +deb-client: $(DEB_CLIENT) ## build the client .deb +deb-server: $(DEB_SERVER) ## build the server .deb + +$(DEB_CLIENT): $(BUILD)/linux-$(DEB_ARCH)/keeporg packaging/deb/client/control.in \ + packaging/deb/changelog packaging/systemd/keeporg.service + scripts/build-deb.sh client $(VERSION_LINUX) $(DEB_ARCH) $@ + +$(DEB_SERVER): $(BUILD)/linux-$(DEB_ARCH)/keeporgd packaging/deb/server/control.in \ + packaging/deb/changelog packaging/systemd/keeporgd.service + scripts/build-deb.sh server $(VERSION_SERVER) $(DEB_ARCH) $@ + +DMG_ARCH ?= $(HOSTARCH) +DMG := $(DIST)/KeepOrg-$(VERSION_MACOS)-$(DMG_ARCH).dmg + +.PHONY: dmg +dmg: $(DMG) ## build the macOS DMG with an /Applications symlink (macOS only) + +$(DMG): $(BUILD)/darwin-$(DMG_ARCH)/keeporg packaging/macos/Info.plist.in \ + packaging/macos/launcher.sh packaging/macos/launch-agent.plist.in + scripts/build-dmg.sh $(VERSION_MACOS) $(DMG_ARCH) $(APPID_MACOS) $@ + +ZIP := $(DIST)/keeporg-$(VERSION_WINDOWS)-windows-amd64.zip + +.PHONY: windows +windows: $(ZIP) ## build the Windows zip + +$(ZIP): $(BUILD)/windows-amd64/keeporg.exe packaging/windows/README.txt + @mkdir -p $(DIST) + rm -rf $(BUILD)/windows-stage && mkdir -p $(BUILD)/windows-stage + cp $(BUILD)/windows-amd64/keeporg.exe $(BUILD)/windows-stage/ + cp packaging/windows/README.txt $(BUILD)/windows-stage/ + cp packaging/windows/keeporg-task.xml $(BUILD)/windows-stage/ + cd $(BUILD)/windows-stage && zip -q -r ../../$@ . + +# -------------------------------------------------------------------- android + +GRADLE ?= $(shell test -x android/gradlew && echo ./gradlew || echo gradle) +APK_BUILT := android/app/build/outputs/apk/release/app-release-unsigned.apk +APK := $(DIST)/$(APPID_ANDROID)_$(ANDROID_VERSION_CODE).apk + +.PHONY: android +android: $(APK) ## build the unsigned release APK under its publishing name + +$(APK): $(shell find android/app/src -type f 2>/dev/null) android/app/build.gradle.kts + cd android && $(GRADLE) --no-daemon assembleRelease \ + -PversionName=$(VERSION_ANDROID) -PversionCode=$(ANDROID_VERSION_CODE) + @mkdir -p $(DIST) + cp $(APK_BUILT) $@ + @printf 'built %s\n' "$@" + +# --------------------------------------------------------------------- publish + +FDROID_HOST ?= rcmd@garage.rcmd.space +FDROID_UNSIGNED ?= /var/lib/fdroid/unsigned +FDROID_METADATA ?= /var/lib/fdroid/metadata + +.PHONY: android-publish +android-publish: $(APK) ## upload the APK and metadata to the private F-Droid repo (manual) + @printf 'publishing %s to %s:%s\n' "$(notdir $(APK))" "$(FDROID_HOST)" "$(FDROID_UNSIGNED)" + scp $(APK) $(FDROID_HOST):$(FDROID_UNSIGNED)/ + scp android/metadata/$(APPID_ANDROID).yml $(FDROID_HOST):$(FDROID_METADATA)/ + @printf 'uploaded. Sign and publish on the repo host with `fdroid publish && fdroid update`.\n' + +.PHONY: dist +dist: deb windows ## build every artifact this host can build + @printf '\nartifacts in %s:\n' "$(DIST)" + @ls -1 $(DIST) + +# -------------------------------------------------------------------- versions + +.PHONY: bump +bump: ## bump a version: make bump PLATFORM=android PART=minor + scripts/bump.sh "$(PLATFORM)" "$(PART)" + +.PHONY: changelog +changelog: ## add a Debian changelog entry: make changelog MSG="what changed" + scripts/changelog.sh "$(VERSION_LINUX)" "$(MSG)" + +# ------------------------------------------------------------------ local runs + +.PHONY: run-server +run-server: $(HOST_SERVER) ## run a server on 127.0.0.1:8765 with data in ./.devdata + KEEPORG_ADMIN_PASSWORD=$${KEEPORG_ADMIN_PASSWORD:-dev-password-1} \ + $(HOST_SERVER) -addr 127.0.0.1:8765 -data .devdata -url http://127.0.0.1:8765 + +.PHONY: run-client +run-client: $(HOST_CLIENT) ## run the sync agent with the local configuration + $(HOST_CLIENT) run diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e2d850 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# KeepOrg + +A synchronization adapter for KeePass. Your databases stay ordinary `.kdbx` +files that KeePassXC, KeePass2 or KeePassDX opens directly; a server keeps +them in step between everyone who has access. + +The server never opens a database and cannot read one. Master passwords stay +on the clients. + +- **Server** (`keeporgd`, Linux): hosts many KDBX databases, controls access + with a tree of user groups, accepts registrations by invite link or OIDC, + and pushes updates to connected clients. +- **Desktop client** (`keeporg`, Linux, macOS, Windows, BSD): mirrors every + assigned database into one directory as a real file, pushes each save and + applies what others push. +- **Android client**: the same, published to every file picker as a storage + provider. + +No third-party dependencies, no cgo, no FUSE. + +## Quick start + +```sh +make check # gofmt, vet, tests +make all # keeporgd and keeporg for this host + +# a server for trying it out, on 127.0.0.1:8765 +KEEPORG_ADMIN_PASSWORD=dev-password-1 make run-server + +# in another shell +build/linux-arm64/keeporg login -server http://127.0.0.1:8765 -user admin +build/linux-arm64/keeporg groups +build/linux-arm64/keeporg db -create "Team Vault" -group +build/linux-arm64/keeporg run +``` + +Then save a new KDBX database as `Team Vault.kdbx` in `~/KeepOrg` from your +KeePass application. It becomes version 1 on the server, and every later save +is pushed automatically. + +## Packaging + +```sh +make deb # Debian packages for the client and the server +make dmg # macOS disk image with an /Applications symlink (on macOS) +make windows # Windows zip with a Task Scheduler job +make android # unsigned release APK (needs the Android SDK) +make help # every target +``` + +## Documentation + +- [docs/HANDOVER.md](docs/HANDOVER.md) — architecture, the decisions behind + it, operations, and what is left to do. +- [docs/PROTOCOL.md](docs/PROTOCOL.md) — the HTTP API. +- [android/README.md](android/README.md) — the Android module. diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..b3ad361 --- /dev/null +++ b/android/README.md @@ -0,0 +1,34 @@ +# KeepOrg for Android + +A KDBX synchronization adapter with no third-party dependencies: the server +is reached with `java.net.HttpURLConnection`, JSON is parsed with the +platform's `org.json`, and the two screens are built in code. + +## How a KeePass application sees it + +`KeepOrgProvider` is a `DocumentsProvider`, so the mirrored databases appear +in every file picker under "KeepOrg". KeePassDX and Keepass2Android open them +like any other document. When the application closes a file it had opened for +writing, the provider triggers a synchronization pass, which is how a save +reaches the server. + +`SyncService` is a foreground service that holds the server's event stream +open, so a change pushed by a colleague arrives without opening the app. The +user turns it on from the main screen; it is not started at boot. + +The merge policy in `Mirror.decide` is the same table as the desktop client's +`internal/client/decide.go`. Keep the two in step. + +## Building + +The Gradle wrapper is not committed. Generate it once with a local Gradle +8.9 or newer, or let the Makefile call Gradle directly: + +``` +gradle wrapper --gradle-version 8.9 # once, optional +make android # unsigned release APK +make android-publish # upload to the private F-Droid repo +``` + +The release build is deliberately unsigned: the private F-Droid repository +signs what it publishes. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..2a00756 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +// Version comes from versions.mk through the Makefile: +// ./gradlew assembleRelease -PversionName=0.1.0 -PversionCode=1 +val appVersionName = (project.findProperty("versionName") as String?) ?: "0.0.0" +val appVersionCode = ((project.findProperty("versionCode") as String?) ?: "1").toInt() + +android { + namespace = "cc.tiredsysadmin.android.keeporg" + compileSdk = 35 + + defaultConfig { + applicationId = "cc.tiredsysadmin.android.keeporg" + minSdk = 26 + targetSdk = 35 + versionCode = appVersionCode + versionName = appVersionName + } + + buildTypes { + release { + isMinifyEnabled = false + // Unsigned on purpose: the private F-Droid repository signs it. + signingConfig = null + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + dependenciesInfo { + // Keep the APK free of the Google dependency metadata blob so the + // build is reproducible for F-Droid. + includeInApk = false + includeInBundle = false + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c5124d7 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Api.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Api.kt new file mode 100644 index 0000000..e76b1fa --- /dev/null +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Api.kt @@ -0,0 +1,244 @@ +package cc.tiredsysadmin.android.keeporg + +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** A structured failure from the server. */ +class ApiException( + val status: Int, + val code: String, + message: String, + val currentVersion: Long = 0, + val mergeStrategy: String = "", +) : IOException(message) { + val isConflict: Boolean get() = code == "conflict" || code == "force_not_allowed" +} + +data class Db( + val id: String, + val name: String, + val perm: String, + val mergeStrategy: String, + val version: Long, + val sha256: String, + val updatedBy: String, +) { + val canWrite: Boolean get() = perm == "write" || perm == "admin" +} + +data class SessionInfo(val token: String, val username: String, val totpEnabled: Boolean) + +data class DeviceStart( + val deviceCode: String, + val userCode: String, + val verificationUrl: String, + val interval: Int, + val expiresIn: Int, +) + +data class PushResult(val version: Long, val sha256: String, val unchanged: Boolean) + +/** + * The KeepOrg HTTP API over the platform's own HTTP client. The server + * contract is small enough that a dependency would cost more than it saves. + */ +class Api(baseUrl: String, var token: String?) { + + private val base: String = baseUrl.trimEnd('/') + + val serverUrl: String get() = base + + fun login(user: String, password: String, totp: String, label: String): SessionInfo { + val body = JSONObject() + .put("username", user).put("password", password) + .put("totp", totp).put("label", label) + return session(request("POST", "/api/v1/auth/login", body)) + } + + fun deviceStart(label: String): DeviceStart { + val o = request("POST", "/api/v1/auth/device/start", JSONObject().put("label", label)) + return DeviceStart( + o.getString("device_code"), + o.getString("user_code"), + o.getString("verification_url"), + o.optInt("interval", 2), + o.optInt("expires_in", 600), + ) + } + + /** Returns null while the browser has not approved the login yet. */ + fun devicePoll(deviceCode: String): SessionInfo? { + return try { + session(request("POST", "/api/v1/auth/device/poll", + JSONObject().put("device_code", deviceCode))) + } catch (e: ApiException) { + if (e.status == 202 || e.code == "pending") null else throw e + } + } + + fun logout() { + try { + request("POST", "/api/v1/auth/logout", null) + } catch (_: IOException) { + // A local sign-out must succeed even when the server is away. + } + } + + fun databases(): List { + val arr = request("GET", "/api/v1/databases", null).getJSONArray("databases") + return (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + Db( + id = o.getString("id"), + name = o.getString("name"), + perm = o.optString("perm"), + mergeStrategy = o.optString("merge_strategy"), + version = o.optLong("version"), + sha256 = o.optString("sha256"), + updatedBy = o.optString("updated_by"), + ) + } + } + + /** Downloads the current bytes and version; version 0 means nothing stored. */ + fun pull(dbId: String): Pair { + val c = open("GET", "/api/v1/databases/${enc(dbId)}/blob") + try { + val status = c.responseCode + if (status == 204) return Pair(ByteArray(0), 0L) + if (status != 200) throw errorOf(c, status) + val data = c.inputStream.use { it.readAllBytesCompat() } + val version = c.getHeaderField("ETag")?.trim('"')?.toLongOrNull() ?: 0L + return Pair(data, version) + } finally { + c.disconnect() + } + } + + fun push(dbId: String, baseVersion: Long, data: ByteArray, force: Boolean): PushResult { + val c = open("PUT", "/api/v1/databases/${enc(dbId)}/blob") + try { + c.setRequestProperty("If-Match", if (force) "*" else baseVersion.toString()) + c.setRequestProperty("Content-Type", "application/octet-stream") + c.doOutput = true + c.setFixedLengthStreamingMode(data.size) + c.outputStream.use { it.write(data) } + val status = c.responseCode + if (status != 200) throw errorOf(c, status) + val o = JSONObject(c.inputStream.use { it.readAllBytesCompat() }.decodeToString()) + return PushResult(o.getLong("version"), o.optString("sha256"), o.optBoolean("unchanged")) + } finally { + c.disconnect() + } + } + + /** + * Reads the server's event stream until it ends or the thread is + * interrupted. Events only ever trigger a full reconcile, so a dropped + * stream costs a delay, never data. + */ + fun events(onEvent: (JSONObject) -> Unit) { + val url = URL("$base/api/v1/events?access_token=" + enc(token ?: "")) + val c = url.openConnection() as HttpURLConnection + c.requestMethod = "GET" + c.setRequestProperty("Accept", "text/event-stream") + c.connectTimeout = 15_000 + // The server sends a keepalive comment every 25 seconds. + c.readTimeout = 70_000 + try { + if (c.responseCode != 200) throw errorOf(c, c.responseCode) + c.inputStream.bufferedReader().use { r -> + var data = StringBuilder() + while (true) { + val line = r.readLine() ?: return + when { + line.isEmpty() -> { + if (data.isNotEmpty()) { + runCatching { onEvent(JSONObject(data.toString())) } + data = StringBuilder() + } + } + line.startsWith(":") -> Unit + line.startsWith("data:") -> data.append(line.removePrefix("data:").trim()) + } + } + } + } finally { + c.disconnect() + } + } + + // ---- plumbing ---- + + private fun session(o: JSONObject): SessionInfo { + val user = o.optJSONObject("user") + return SessionInfo( + o.getString("token"), + user?.optString("username") ?: "", + user?.optBoolean("totp_enabled") ?: false, + ) + } + + private fun open(method: String, path: String): HttpURLConnection { + val c = URL(base + path).openConnection() as HttpURLConnection + c.requestMethod = method + c.connectTimeout = 15_000 + c.readTimeout = 60_000 + c.setRequestProperty("Accept", "application/json") + token?.takeIf { it.isNotEmpty() }?.let { c.setRequestProperty("Authorization", "Bearer $it") } + return c + } + + private fun request(method: String, path: String, body: JSONObject?): JSONObject { + val c = open(method, path) + try { + if (body != null) { + c.setRequestProperty("Content-Type", "application/json") + c.doOutput = true + c.outputStream.use { it.write(body.toString().toByteArray()) } + } + val status = c.responseCode + if (status < 200 || status > 299) throw errorOf(c, status) + if (status == 202) throw ApiException(202, "pending", "pending") + val text = c.inputStream.use { it.readAllBytesCompat() }.decodeToString() + return if (text.isBlank()) JSONObject() else JSONObject(text) + } finally { + c.disconnect() + } + } + + private fun errorOf(c: HttpURLConnection, status: Int): ApiException { + val text = try { + (c.errorStream ?: c.inputStream)?.use { it.readAllBytesCompat() }?.decodeToString() ?: "" + } catch (_: IOException) { + "" + } + val o = runCatching { JSONObject(text) }.getOrNull() + return ApiException( + status = status, + code = o?.optString("code") ?: "", + message = o?.optString("error")?.takeIf { it.isNotEmpty() } ?: "server returned $status", + currentVersion = o?.optLong("current_version") ?: 0L, + mergeStrategy = o?.optString("merge_strategy") ?: "", + ) + } + + private fun enc(s: String): String = URLEncoder.encode(s, "UTF-8") +} + +/** readAllBytes() needs API 33; this works on every supported release. */ +internal fun InputStream.readAllBytesCompat(): ByteArray { + val out = ByteArrayOutputStream() + val buf = ByteArray(16 * 1024) + while (true) { + val n = read(buf) + if (n < 0) break + out.write(buf, 0, n) + } + return out.toByteArray() +} diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/KeepOrgProvider.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/KeepOrgProvider.kt new file mode 100644 index 0000000..9421e45 --- /dev/null +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/KeepOrgProvider.kt @@ -0,0 +1,182 @@ +package cc.tiredsysadmin.android.keeporg + +import android.database.Cursor +import android.database.MatrixCursor +import android.os.CancellationSignal +import android.os.Handler +import android.os.HandlerThread +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract.Document +import android.provider.DocumentsContract.Root +import android.provider.DocumentsProvider +import java.io.File +import java.io.FileNotFoundException + +/** + * Publishes the mirrored databases as a storage provider, so any KeePass + * application on the device opens them through the normal file picker and + * saves back into them. A save closes the descriptor, which is where a + * synchronization pass is triggered. + */ +class KeepOrgProvider : DocumentsProvider() { + + private lateinit var closeThread: HandlerThread + private lateinit var closeHandler: Handler + + override fun onCreate(): Boolean { + closeThread = HandlerThread("keeporg-doc-close").apply { start() } + closeHandler = Handler(closeThread.looper) + return true + } + + override fun shutdown() { + closeThread.quitSafely() + } + + private fun prefs() = Prefs(requireContext()) + + private fun mirrorDir(): File = File(requireContext().filesDir, "databases").apply { mkdirs() } + + private fun requireContext() = context ?: throw FileNotFoundException("no context") + + override fun queryRoots(projection: Array?): Cursor { + val c = MatrixCursor(projection ?: ROOT_PROJECTION) + val p = prefs() + if (!p.signedIn) return c // signed out: the provider shows no root at all + c.newRow().apply { + add(Root.COLUMN_ROOT_ID, ROOT_ID) + add(Root.COLUMN_DOCUMENT_ID, ROOT_DOC) + add(Root.COLUMN_TITLE, requireContext().getString(R.string.app_name)) + add(Root.COLUMN_SUMMARY, p.username.ifEmpty { p.serverUrl }) + add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_IS_CHILD or Root.FLAG_LOCAL_ONLY) + add(Root.COLUMN_MIME_TYPES, KDBX_MIME) + add(Root.COLUMN_ICON, R.mipmap.ic_launcher) + } + return c + } + + override fun queryDocument(documentId: String, projection: Array?): Cursor { + val c = MatrixCursor(projection ?: DOC_PROJECTION) + if (documentId == ROOT_DOC) { + c.newRow().apply { + add(Document.COLUMN_DOCUMENT_ID, ROOT_DOC) + add(Document.COLUMN_DISPLAY_NAME, requireContext().getString(R.string.provider_root)) + add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) + add(Document.COLUMN_FLAGS, Document.FLAG_DIR_SUPPORTS_CREATE) + add(Document.COLUMN_SIZE, null) + add(Document.COLUMN_LAST_MODIFIED, mirrorDir().lastModified()) + } + return c + } + addFileRow(c, fileOf(documentId)) + return c + } + + override fun queryChildDocuments( + parentDocumentId: String, + projection: Array?, + sortOrder: String?, + ): Cursor { + if (parentDocumentId != ROOT_DOC) throw FileNotFoundException("not a directory") + val c = MatrixCursor(projection ?: DOC_PROJECTION) + mirrorDir().listFiles() + ?.filter { it.isFile && it.name.endsWith(".kdbx") && !it.name.endsWith(".keeporg-tmp") } + ?.sortedBy { it.name } + ?.forEach { addFileRow(c, it) } + // Ask for a fresh pass so a picker that is open shows current files. + Syncer.request(requireContext(), "picker listed documents") + return c + } + + override fun openDocument( + documentId: String, + mode: String, + signal: CancellationSignal?, + ): ParcelFileDescriptor { + val file = fileOf(documentId) + val access = ParcelFileDescriptor.parseMode(mode) + if (!mode.contains("w")) return ParcelFileDescriptor.open(file, access) + // The application is about to save: reconcile once it has closed the + // file, which is the moment the new bytes are complete on disk. + return ParcelFileDescriptor.open(file, access, closeHandler) { + Syncer.request(requireContext(), "saved $documentId") + } + } + + /** + * Creating a document is allowed only under the name of a database the + * server has assigned to this device but that has no content yet. That + * is how a brand new shared database gets its first bytes from a KeePass + * application, without the picker inventing files the server knows + * nothing about. + */ + override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { + if (parentDocumentId != ROOT_DOC) throw FileNotFoundException("not a directory") + val wanted = if (displayName.endsWith(".kdbx")) displayName else "$displayName.kdbx" + val p = prefs() + val known = p.trackedIds.any { p.state(it).file == wanted } + if (!known) { + throw FileNotFoundException( + "no database named ${wanted.removeSuffix(".kdbx")} is assigned to you; " + + "create it on the server first" + ) + } + val file = File(mirrorDir(), wanted) + if (!file.exists() && !file.createNewFile()) { + throw FileNotFoundException("could not create $wanted") + } + return wanted + } + + /** Only the conflict copies this app wrote may be deleted from a picker. */ + override fun deleteDocument(documentId: String) { + if (!isDisposable(documentId)) { + throw UnsupportedOperationException("a synced database cannot be deleted here") + } + if (!fileOf(documentId).delete()) throw FileNotFoundException(documentId) + } + + override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = + parentDocumentId == ROOT_DOC && documentId != ROOT_DOC + + private fun addFileRow(c: MatrixCursor, file: File) { + var flags = Document.FLAG_SUPPORTS_WRITE + if (isDisposable(file.name)) flags = flags or Document.FLAG_SUPPORTS_DELETE + c.newRow().apply { + add(Document.COLUMN_DOCUMENT_ID, file.name) + add(Document.COLUMN_DISPLAY_NAME, file.name) + add(Document.COLUMN_MIME_TYPE, KDBX_MIME) + add(Document.COLUMN_SIZE, file.length()) + add(Document.COLUMN_LAST_MODIFIED, file.lastModified()) + add(Document.COLUMN_FLAGS, flags) + } + } + + private fun fileOf(documentId: String): File { + if (documentId.contains('/') || documentId.contains("..")) { + throw FileNotFoundException("bad document id") + } + val f = File(mirrorDir(), documentId) + if (!f.isFile) throw FileNotFoundException(documentId) + return f + } + + private fun isDisposable(name: String) = + name.contains(".conflict-") || name.contains(".server-v") + + companion object { + const val AUTHORITY = "cc.tiredsysadmin.android.keeporg.documents" + const val ROOT_ID = "keeporg" + const val ROOT_DOC = "root" + const val KDBX_MIME = "application/x-keepass2" + + private val ROOT_PROJECTION = arrayOf( + Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE, + Root.COLUMN_SUMMARY, Root.COLUMN_FLAGS, Root.COLUMN_MIME_TYPES, Root.COLUMN_ICON, + ) + private val DOC_PROJECTION = arrayOf( + Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE, + Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, + ) + } +} diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/LoginActivity.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/LoginActivity.kt new file mode 100644 index 0000000..7781a67 --- /dev/null +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/LoginActivity.kt @@ -0,0 +1,116 @@ +package cc.tiredsysadmin.android.keeporg + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.InputType +import android.widget.TextView + +/** + * Signs the device in, either with a password and the built-in second + * factor, or by approving a code in a browser, which is also how single + * sign-on works. + */ +class LoginActivity : Activity() { + + private lateinit var message: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val prefs = Prefs(this) + val root = Ui.column(this) + + val server = Ui.field(this, "https://keeporg.example.com", InputType.TYPE_TEXT_VARIATION_URI) + server.setText(prefs.serverUrl) + val user = Ui.field(this, "username", InputType.TYPE_CLASS_TEXT) + val password = Ui.field( + this, "password", + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + ) + val totp = Ui.field(this, "one-time code (if enabled)", InputType.TYPE_CLASS_NUMBER) + message = Ui.body(this) + + root.addView(Ui.heading(this, getString(R.string.sign_in))) + root.addView(Ui.label(this, "Server")) + root.addView(server) + root.addView(Ui.label(this, "Username")) + root.addView(user) + root.addView(Ui.label(this, "Password")) + root.addView(password) + root.addView(Ui.label(this, "Second factor")) + root.addView(totp) + root.addView(Ui.button(this, "Sign in") { + val url = server.text.toString().trim().trimEnd('/') + if (url.isEmpty()) { + message.text = "Enter the server address." + return@button + } + prefs.serverUrl = url + message.text = "Signing in..." + val api = Api(url, null) + Ui.background(this, { + api.login(user.text.toString().trim(), password.text.toString(), + totp.text.toString().trim(), android.os.Build.MODEL ?: "android") + }) { result -> + result.fold({ finishSignIn(it) }, { failed(it) }) + } + }) + root.addView(Ui.button(this, "Sign in with a browser or SSO") { + val url = server.text.toString().trim().trimEnd('/') + if (url.isEmpty()) { + message.text = "Enter the server address." + return@button + } + prefs.serverUrl = url + browserSignIn(Api(url, null)) + }) + root.addView(message) + setContentView(root) + } + + /** Runs the device flow: show a code, open a browser, wait for approval. */ + private fun browserSignIn(api: Api) { + message.text = "Opening a browser..." + Ui.background(this, { api.deviceStart(android.os.Build.MODEL ?: "android") }) { started -> + started.fold({ start -> + message.text = "Approve the code ${start.userCode} in the browser." + runCatching { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(start.verificationUrl))) + } + Ui.background(this, { poll(api, start) }) { polled -> + polled.fold({ session -> + if (session == null) { + message.text = "The approval window expired. Try again." + } else { + finishSignIn(session) + } + }, { failed(it) }) + } + }, { failed(it) }) + } + } + + private fun poll(api: Api, start: DeviceStart): SessionInfo? { + val deadline = System.currentTimeMillis() + start.expiresIn * 1000L + val interval = (start.interval.coerceAtLeast(1)) * 1000L + while (System.currentTimeMillis() < deadline) { + Thread.sleep(interval) + api.devicePoll(start.deviceCode)?.let { return it } + } + return null + } + + private fun finishSignIn(session: SessionInfo) { + val prefs = Prefs(this) + prefs.token = session.token + prefs.username = session.username + Syncer.request(this, "signed in") + startActivity(Intent(this, MainActivity::class.java)) + finish() + } + + private fun failed(e: Throwable) { + message.text = e.message ?: "Sign-in failed." + } +} diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/MainActivity.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/MainActivity.kt new file mode 100644 index 0000000..7a6759c --- /dev/null +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/MainActivity.kt @@ -0,0 +1,121 @@ +package cc.tiredsysadmin.android.keeporg + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.widget.Button +import android.widget.TextView + +/** + * One screen: which databases are mirrored, what state each is in, and the + * switch that keeps the device listening for other people's changes. + */ +class MainActivity : Activity() { + + private lateinit var prefs: Prefs + private lateinit var status: TextView + private lateinit var watchButton: Button + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + prefs = Prefs(this) + if (!prefs.signedIn) { + startActivity(Intent(this, LoginActivity::class.java)) + finish() + return + } + + val root = Ui.column(this) + root.addView(Ui.heading(this, getString(R.string.app_name))) + root.addView(Ui.body(this, "${prefs.username} at ${prefs.serverUrl}")) + root.addView( + Ui.label( + this, + "Open these databases from any KeePass application: pick " + + "\"KeepOrg\" in its file picker." + ) + ) + status = Ui.body(this, "Loading...") + root.addView(status) + + root.addView(Ui.button(this, "Sync now") { refresh(force = true) }) + watchButton = Ui.button(this, watchLabel()) { toggleWatch() } + root.addView(watchButton) + root.addView(Ui.button(this, "Sign out") { signOut() }) + setContentView(root) + } + + override fun onResume() { + super.onResume() + if (!prefs.signedIn) return + // Restart the watcher if the system stopped it while we were away. + if (prefs.watchEnabled) SyncService.start(this) + refresh(force = false) + } + + private fun refresh(force: Boolean) { + status.text = if (force) "Synchronizing..." else status.text + val api = Syncer.apiOf(this) ?: return + Syncer.request(this, if (force) "user asked" else "screen opened") { } + Ui.background(this, { api.databases() }) { result -> + result.fold({ dbs -> + status.text = if (dbs.isEmpty()) { + "No databases are assigned to this account yet." + } else { + dbs.joinToString("\n\n") { db -> + val state = prefs.status(db.id).ifEmpty { "not synced yet" } + "${db.name}\n ${db.perm}, ${db.mergeStrategy}, v${db.version}\n $state" + } + } + }, { e -> + status.text = "Could not reach the server: ${e.message}" + }) + } + } + + private fun watchLabel() = + if (prefs.watchEnabled) "Stop watching for changes" else "Keep watching for changes" + + private fun toggleWatch() { + if (prefs.watchEnabled) { + prefs.watchEnabled = false + SyncService.stop(this) + } else { + if (Build.VERSION.SDK_INT >= 33 && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1) + return + } + prefs.watchEnabled = true + SyncService.start(this) + } + watchButton.text = watchLabel() + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 1) { + prefs.watchEnabled = true + SyncService.start(this) + watchButton.text = watchLabel() + } + } + + private fun signOut() { + val api = Syncer.apiOf(this) + SyncService.stop(this) + prefs.signOut() + Ui.background(this, { api?.logout() }) { + startActivity(Intent(this, LoginActivity::class.java)) + finish() + } + } +} diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Mirror.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Mirror.kt new file mode 100644 index 0000000..1748ac0 --- /dev/null +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Mirror.kt @@ -0,0 +1,244 @@ +package cc.tiredsysadmin.android.keeporg + +import android.content.Context +import android.provider.DocumentsContract +import android.util.Log +import java.io.File +import java.io.IOException +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Keeps a directory of real .kdbx files in step with the server. The files + * are published to every file picker by [KeepOrgProvider], so KeePassDX or + * Keepass2Android opens them as ordinary documents. + * + * The decision table below is the same policy the desktop client applies, so + * both platforms behave identically in a conflict. + */ +class Mirror(private val context: Context, private val api: Api, private val prefs: Prefs) { + + enum class Action { NOTHING, PULL, PUSH, FORCE_PUSH, SERVER_WINS, RESTORE, MANUAL } + + /** The mirror directory, private to the app. */ + val dir: File = File(context.filesDir, "databases").apply { mkdirs() } + + @Synchronized + fun syncAll(): Result { + val dbs = try { + api.databases() + } catch (e: IOException) { + return Result.failure(e) + } + var firstError: Throwable? = null + for (db in dbs) { + try { + reconcile(db) + } catch (e: IOException) { + prefs.putStatus(db.id, "error: ${e.message}") + Log.w(TAG, "reconcile ${db.name}", e) + if (firstError == null) firstError = e + } + } + val seen = dbs.map { it.id }.toSet() + for (gone in prefs.trackedIds - seen) { + // The file itself stays: deleting someone's password database is + // not this app's decision. + prefs.forgetState(gone) + } + prefs.trackedIds = seen + notifyPicker() + return if (firstError == null) Result.success(Unit) else Result.failure(firstError) + } + + /** Decides and applies the one action this database needs now. */ + private fun reconcile(db: Db) { + val st = trackedState(db) + val file = File(dir, st.file) + val localExists = file.isFile + val local = if (localExists) file.readBytes() else ByteArray(0) + val localSha = if (localExists) digest(local) else "" + + val action = decide( + localExists = localExists, + localSha = localSha, + syncedSha = st.sha256, + syncedVersion = st.version, + remoteSha = db.sha256, + remoteVersion = db.version, + canWrite = db.canWrite, + strategy = db.mergeStrategy, + conflictSeen = st.conflictMarker == conflictMarker(db.version, localSha), + ) + if (action != Action.MANUAL && st.conflictMarker.isNotEmpty()) st.conflictMarker = "" + + when (action) { + Action.NOTHING -> if (prefs.status(db.id).isEmpty()) prefs.putStatus(db.id, "in sync") + + Action.PULL -> pull(db, st, file) + + Action.PUSH -> push(db, st, file, local, force = false) + + Action.FORCE_PUSH -> push(db, st, file, local, force = true) + + Action.SERVER_WINS, Action.RESTORE -> { + val kept = keepConflictCopy(file, local) + pull(db, st, file) + val why = if (action == Action.RESTORE) + "read-only access: your save was kept as ${kept.name}" + else + "the server copy was newer: your save was kept as ${kept.name}" + prefs.putStatus(db.id, why) + } + + Action.MANUAL -> { + val (remote, version) = api.pull(db.id) + val side = File(dir, st.file.removeSuffix(".kdbx") + ".server-v$version.kdbx") + writeAtomic(side, remote) + st.conflictMarker = conflictMarker(db.version, localSha) + prefs.putStatus(db.id, "both copies changed: the server copy is in ${side.name}") + } + } + prefs.putState(db.id, st) + } + + private fun pull(db: Db, st: Prefs.FileState, file: File) { + val (data, version) = api.pull(db.id) + if (version == 0L || data.isEmpty()) return + writeAtomic(file, data) + st.version = version + st.sha256 = digest(data) + prefs.putStatus(db.id, "pulled version $version") + } + + private fun push(db: Db, st: Prefs.FileState, file: File, data: ByteArray, force: Boolean) { + try { + val res = api.push(db.id, st.version, data, force) + st.version = res.version + st.sha256 = res.sha256 + prefs.putStatus(db.id, if (res.unchanged) "in sync" else "pushed version ${res.version}") + } catch (e: ApiException) { + when { + e.isConflict -> { + val strategy = e.mergeStrategy.ifEmpty { db.mergeStrategy } + if (strategy == "client-wins" && !force) { + push(db, st, file, data, force = true) + return + } + val kept = keepConflictCopy(file, data) + pull(db, st, file) + prefs.putStatus(db.id, "the server copy was newer: your save was kept as ${kept.name}") + } + e.status == 403 -> { + val kept = keepConflictCopy(file, data) + pull(db, st, file) + prefs.putStatus(db.id, "read-only access: your save was kept as ${kept.name}") + } + else -> throw e + } + } + } + + /** Returns the state for a database, following a rename made on the server. */ + private fun trackedState(db: Db): Prefs.FileState { + val st = prefs.state(db.id) + if (st.file.isEmpty()) { + st.name = db.name + st.file = fileName(db) + return st + } + if (st.name != db.name) { + val want = fileName(db) + val old = File(dir, st.file) + if (old.isFile && want != st.file) { + if (!old.renameTo(File(dir, want))) { + Log.w(TAG, "could not rename ${st.file} to $want") + return st + } + } + st.name = db.name + st.file = want + } + return st + } + + private fun fileName(db: Db): String { + val base = sanitize(db.name).ifEmpty { "database" } + val name = "$base.kdbx" + val taken = prefs.trackedIds.any { it != db.id && prefs.state(it).file == name } + return if (taken) "$base-${db.id.take(6)}.kdbx" else name + } + + private fun keepConflictCopy(file: File, data: ByteArray): File { + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + val copy = File(dir, file.name.removeSuffix(".kdbx") + ".conflict-$stamp.kdbx") + if (data.isNotEmpty()) writeAtomic(copy, data) + return copy + } + + /** Tells every open file picker that the listing changed. */ + fun notifyPicker() { + val uri = DocumentsContract.buildChildDocumentsUri(KeepOrgProvider.AUTHORITY, KeepOrgProvider.ROOT_DOC) + runCatching { context.contentResolver.notifyChange(uri, null) } + } + + companion object { + private const val TAG = "KeepOrgMirror" + + /** + * The whole client-side synchronization policy, kept pure so it can + * be read in one place and matched against the desktop client. + */ + fun decide( + localExists: Boolean, + localSha: String, + syncedSha: String, + syncedVersion: Long, + remoteSha: String, + remoteVersion: Long, + canWrite: Boolean, + strategy: String, + conflictSeen: Boolean, + ): Action { + val localChanged = localExists && localSha != syncedSha + val remoteChanged = remoteVersion != syncedVersion || remoteSha != syncedSha + return when { + !localExists -> if (remoteVersion > 0) Action.PULL else Action.NOTHING + localChanged && !remoteChanged -> if (canWrite) Action.PUSH else Action.RESTORE + !localChanged && remoteChanged -> Action.PULL + localChanged && remoteChanged -> when { + !canWrite -> Action.RESTORE + strategy == "client-wins" -> Action.FORCE_PUSH + strategy == "manual" -> if (conflictSeen) Action.NOTHING else Action.MANUAL + else -> Action.SERVER_WINS + } + else -> Action.NOTHING + } + } + + fun digest(data: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(data) + .joinToString("") { "%02x".format(it) } + + fun conflictMarker(remoteVersion: Long, localSha: String): String = + if (localSha.isEmpty()) "" else "$remoteVersion/$localSha" + + /** Replaces a file in one step, so a reader never sees half a database. */ + fun writeAtomic(target: File, data: ByteArray) { + val tmp = File(target.parentFile, target.name + ".keeporg-tmp") + tmp.outputStream().use { it.write(data); it.fd.sync() } + if (!tmp.renameTo(target)) { + tmp.delete() + throw IOException("could not replace ${target.name}") + } + } + + fun sanitize(name: String): String = buildString { + for (ch in name.trim()) { + append(if (ch.isLetterOrDigit() || ch in "-_. ") ch else '-') + } + }.removeSuffix(".kdbx").trim(' ', '.', '-') + } +} diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt new file mode 100644 index 0000000..f07655e --- /dev/null +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Prefs.kt @@ -0,0 +1,77 @@ +package cc.tiredsysadmin.android.keeporg + +import android.content.Context +import org.json.JSONObject + +/** + * Everything the app remembers: where the server is, this device's session + * token, and what this device last agreed with the server about each + * database. Master passwords are never stored, because the app never opens a + * database; it only moves bytes for the KeePass application that does. + */ +class Prefs(context: Context) { + + private val sp = context.getSharedPreferences("keeporg", Context.MODE_PRIVATE) + + var serverUrl: String + get() = sp.getString("server_url", "") ?: "" + set(v) = sp.edit().putString("server_url", v).apply() + + var token: String + get() = sp.getString("token", "") ?: "" + set(v) = sp.edit().putString("token", v).apply() + + var username: String + get() = sp.getString("username", "") ?: "" + set(v) = sp.edit().putString("username", v).apply() + + val signedIn: Boolean get() = serverUrl.isNotEmpty() && token.isNotEmpty() + + /** Whether the user asked this device to keep listening for changes. */ + var watchEnabled: Boolean + get() = sp.getBoolean("watch_enabled", false) + set(v) = sp.edit().putBoolean("watch_enabled", v).apply() + + fun signOut() = sp.edit().clear().apply() + + /** What this device last synced for one database. */ + data class FileState( + var name: String = "", + var file: String = "", + var version: Long = 0, + var sha256: String = "", + var conflictMarker: String = "", + ) + + fun state(dbId: String): FileState { + val raw = sp.getString("state_$dbId", null) ?: return FileState() + val o = runCatching { JSONObject(raw) }.getOrNull() ?: return FileState() + return FileState( + o.optString("name"), + o.optString("file"), + o.optLong("version"), + o.optString("sha256"), + o.optString("conflict_marker"), + ) + } + + fun putState(dbId: String, st: FileState) { + val o = JSONObject() + .put("name", st.name).put("file", st.file) + .put("version", st.version).put("sha256", st.sha256) + .put("conflict_marker", st.conflictMarker) + sp.edit().putString("state_$dbId", o.toString()).apply() + } + + fun forgetState(dbId: String) = sp.edit().remove("state_$dbId").apply() + + /** Known database ids, so files of unassigned databases stop being tracked. */ + var trackedIds: Set + get() = sp.getStringSet("tracked", emptySet()) ?: emptySet() + set(v) = sp.edit().putStringSet("tracked", v).apply() + + /** The last status line shown on the main screen, per database. */ + fun putStatus(dbId: String, text: String) = sp.edit().putString("status_$dbId", text).apply() + + fun status(dbId: String): String = sp.getString("status_$dbId", "") ?: "" +} diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/SyncService.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/SyncService.kt new file mode 100644 index 0000000..2fffa32 --- /dev/null +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/SyncService.kt @@ -0,0 +1,111 @@ +package cc.tiredsysadmin.android.keeporg + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Log +import java.io.IOException + +/** + * Holds the server's event stream open so a change made by a colleague + * reaches this device without the user opening the app. A notification is + * required for that, and it doubles as the switch the user can see. + */ +class SyncService : Service() { + + @Volatile + private var running = false + private var worker: Thread? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!Prefs(this).signedIn) { + stopSelf() + return START_NOT_STICKY + } + startForeground(NOTIFICATION_ID, notification("Watching for changes")) + Syncer.request(this, "service started") + if (!running) { + running = true + worker = Thread({ listen() }, "keeporg-events").apply { isDaemon = true; start() } + } + return START_STICKY + } + + override fun onDestroy() { + running = false + worker?.interrupt() + super.onDestroy() + } + + /** Reconnects to the event stream for as long as the service lives. */ + private fun listen() { + var backoff = 1000L + while (running) { + val api = Syncer.apiOf(this) + if (api == null) { + stopSelf() + return + } + try { + api.events { ev -> + backoff = 1000L + Log.d(TAG, "event ${ev.optString("type")} ${ev.optString("name")}") + Syncer.request(this, "server event") + } + } catch (e: IOException) { + if (!running) return + Log.d(TAG, "event stream: ${e.message}") + } catch (e: InterruptedException) { + return + } + // A dropped stream only delays a reconcile, so retry gently and + // reconcile once on the way past in case something was missed. + Syncer.request(this, "stream reconnect") + try { + Thread.sleep(backoff) + } catch (e: InterruptedException) { + return + } + if (backoff < 60_000L) backoff *= 2 + } + } + + private fun notification(text: String): Notification { + val nm = getSystemService(NotificationManager::class.java) + nm.createNotificationChannel( + NotificationChannel(CHANNEL, getString(R.string.sync_channel), NotificationManager.IMPORTANCE_LOW) + ) + val open = PendingIntent.getActivity( + this, 0, Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + return Notification.Builder(this, CHANNEL) + .setContentTitle(getString(R.string.app_name)) + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setContentIntent(open) + .setOngoing(true) + .build() + } + + companion object { + private const val TAG = "KeepOrgService" + private const val CHANNEL = "sync" + private const val NOTIFICATION_ID = 1 + + fun start(context: Context) { + context.startForegroundService(Intent(context, SyncService::class.java)) + } + + fun stop(context: Context) { + context.stopService(Intent(context, SyncService::class.java)) + } + } +} diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Syncer.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Syncer.kt new file mode 100644 index 0000000..bdd1871 --- /dev/null +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Syncer.kt @@ -0,0 +1,48 @@ +package cc.tiredsysadmin.android.keeporg + +import android.content.Context +import android.util.Log +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The one place a synchronization pass is run. Work is serialized on a single + * thread, and a request that arrives while a pass is already queued is + * folded into it, so a burst of saves costs one reconcile. + */ +object Syncer { + + private const val TAG = "KeepOrgSyncer" + private val pool = Executors.newSingleThreadExecutor() + private val queued = AtomicBoolean(false) + + @Volatile + var lastError: String? = null + private set + + /** Builds an API client from the stored session, or null when signed out. */ + fun apiOf(context: Context): Api? { + val prefs = Prefs(context) + if (!prefs.signedIn) return null + return Api(prefs.serverUrl, prefs.token) + } + + fun mirrorOf(context: Context): Mirror? { + val api = apiOf(context) ?: return null + return Mirror(context.applicationContext, api, Prefs(context)) + } + + /** Queues a pass. [done] runs on the worker thread when it finishes. */ + fun request(context: Context, reason: String, done: ((Result) -> Unit)? = null) { + val app = context.applicationContext + if (done == null && !queued.compareAndSet(false, true)) return + pool.execute { + if (done == null) queued.set(false) + Log.d(TAG, "sync: $reason") + val mirror = mirrorOf(app) + val result = mirror?.syncAll() ?: Result.failure(IllegalStateException("signed out")) + lastError = result.exceptionOrNull()?.message + done?.invoke(result) + } + } +} diff --git a/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Ui.kt b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Ui.kt new file mode 100644 index 0000000..42b5e1b --- /dev/null +++ b/android/app/src/main/java/cc/tiredsysadmin/android/keeporg/Ui.kt @@ -0,0 +1,71 @@ +package cc.tiredsysadmin.android.keeporg + +import android.app.Activity +import android.content.Context +import android.graphics.Typeface +import android.util.TypedValue +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView + +/** + * The two screens are built in code. The app has no third-party UI library + * and needs no layout files, which keeps the APK and the build minimal. + */ +internal object Ui { + + fun dp(context: Context, value: Int): Int = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), context.resources.displayMetrics + ).toInt() + + fun column(context: Context): LinearLayout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + val pad = dp(context, 20) + setPadding(pad, pad, pad, pad) + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + fun heading(context: Context, text: String): TextView = TextView(context).apply { + this.text = text + textSize = 20f + setTypeface(typeface, Typeface.BOLD) + setPadding(0, 0, 0, dp(context, 12)) + } + + fun label(context: Context, text: String): TextView = TextView(context).apply { + this.text = text + textSize = 13f + alpha = 0.7f + setPadding(0, dp(context, 12), 0, dp(context, 2)) + } + + fun body(context: Context, text: String = ""): TextView = TextView(context).apply { + this.text = text + textSize = 14f + setTypeface(Typeface.MONOSPACE) + setPadding(0, dp(context, 8), 0, dp(context, 8)) + } + + fun field(context: Context, hint: String, inputType: Int): EditText = EditText(context).apply { + this.hint = hint + this.inputType = inputType + } + + fun button(context: Context, text: String, onClick: () -> Unit): Button = Button(context).apply { + this.text = text + setOnClickListener { onClick() } + } + + /** Runs work off the main thread and delivers the result back on it. */ + fun background(activity: Activity, work: () -> T, then: (Result) -> Unit) { + Thread { + val result = runCatching(work) + activity.runOnUiThread { if (!activity.isFinishing) then(result) } + }.start() + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2d3fe5e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..f103301 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ad345f1 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #2D6A4F + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6ec3cd0 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + KeepOrg + Sign in + KeepOrg databases + Synchronization + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..6f03db4 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,7 @@ +// Root build file. The application has no third-party dependencies: it talks +// to the server with java.net.HttpURLConnection and draws its two screens in +// code, which keeps the APK small and an F-Droid build reproducible. +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..27b2234 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g +android.useAndroidX=false +kotlin.code.style=official diff --git a/android/metadata/cc.tiredsysadmin.android.keeporg.yml b/android/metadata/cc.tiredsysadmin.android.keeporg.yml new file mode 100644 index 0000000..7493b73 --- /dev/null +++ b/android/metadata/cc.tiredsysadmin.android.keeporg.yml @@ -0,0 +1,44 @@ +# F-Droid metadata for the private repository. +# Publish with: make android-publish (copies this file to the metadata +# directory and the unsigned APK to the unsigned directory). +Categories: + - Security +License: AGPL-3.0-or-later +AuthorName: tiredsysadmin +SourceCode: https://garage.rcmd.space/keeporg +IssueTracker: https://garage.rcmd.space/keeporg/issues + +Name: KeepOrg +Summary: Collaborative sync adapter for KeePass databases +Description: |- + KeepOrg mirrors the KDBX databases a KeepOrg server has assigned to you and + publishes them to every file picker on the device, so KeePassDX or + Keepass2Android opens them as ordinary files. + + Each save is pushed to the server, and changes other people push are applied + here. When two people save at once, the database's merge strategy decides + the outcome; the losing copy is always kept beside the file so nothing is + lost silently. + + The app never opens a database and never sees a master password. It moves + encrypted bytes and nothing else. + + Sign in with a password and the server's built-in time-based second factor, + or approve the device in a browser, which is also how single sign-on works. + +RepoType: git +Repo: https://garage.rcmd.space/keeporg.git + +Builds: + - versionName: 0.1.0 + versionCode: 1 + commit: v0.1.0-android + subdir: android/app + gradle: + - yes + output: android/app/build/outputs/apk/release/app-release-unsigned.apk + +AutoUpdateMode: None +UpdateCheckMode: Tags +CurrentVersion: 0.1.0 +CurrentVersionCode: 1 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..1a0a749 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} +rootProject.name = "KeepOrg" +include(":app") diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md new file mode 100644 index 0000000..34de0f1 --- /dev/null +++ b/docs/HANDOVER.md @@ -0,0 +1,318 @@ +# KeepOrg: developer handover + +KeepOrg makes a KeePass database collaborative without changing KeePass. A +server holds many KDBX files and arbitrates who wrote last; clients present +each assigned database as an ordinary `.kdbx` file that any KeePass +application can open, and synchronize it on every save. + +The server never opens a database. It moves encrypted bytes and metadata +about them. Master passwords and key files stay on the clients, so a +compromised server leaks the ciphertext and the group structure, nothing else. + +## Repository map + +``` +cmd/keeporgd/ server entry point: flags, config file, bootstrap +cmd/keeporg/ desktop client: login, run, sync, status, administration +internal/store/ domain model, JSON metadata store, ACL resolution +internal/blob/ versioned KDBX payload storage on disk +internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, pages +internal/client/ shared client engine: API wrapper, merge policy, mirror +internal/secret/ password verifiers, tokens, identifiers +internal/totp/ RFC 6238 second factor +android/ Android client: DocumentsProvider, sync service, UI +packaging/ deb, macOS bundle and DMG, Windows zip, systemd units +scripts/ package builders, version bump, changelog +versions.mk the one place every platform's version lives +Makefile the only entry point for building and publishing +``` + +## The three programs + +### Server (`keeporgd`) + +One process, one data directory, no database engine: + +- `state.json` holds users, groups, memberships, databases, grants, invites, + sessions and pending device logins. +- `blobs//.kdbx` holds the payloads, keeping the last + 20 versions of each database. + +`internal/store.Store` is the only writer of `state.json`. Every mutation +runs through `Update(func(*State) error)`, which applies the change to a +private copy, writes it with `write` + `fsync` + `rename`, and only then +publishes it. A failed mutation changes nothing, and a crash leaves either +the old document or the new one. + +Reads go through `Read(func(*State))` under a read lock. Access decisions are +pure functions over `*State` in `internal/store/acl.go`, which is why they +are cheap enough to call per request and easy to test exhaustively. + +### Desktop client (`keeporg`) + +`keeporg run` keeps a mirror directory (default `~/KeepOrg`) in step with the +server: + +- a rescan every two seconds notices that a KeePass application saved a file; +- the server's event stream delivers other people's pushes immediately; +- `.keeporg-state.json` in the mirror records what this device last agreed + with the server for each database. + +All of the synchronization policy is the pure `decide()` table in +`internal/client/decide.go`. Everything else in the mirror applies the action +it returns. To change behaviour, change that table and its test. + +### Android client + +`KeepOrgProvider` is a `DocumentsProvider`, so the mirrored files appear in +every file picker under "KeepOrg" and KeePassDX or Keepass2Android opens them +as ordinary documents. When such an application closes a file it had open for +writing, the provider triggers a synchronization pass; that is how a save +reaches the server. `SyncService` is a foreground service that holds the +event stream open so colleagues' changes arrive without opening the app. + +`Mirror.decide` is a transcription of the Go decision table. **Keep the two in +step**: a change to one belongs in the other in the same commit. + +## Access control + +Groups form one tree. Two rules decide everything: + +1. **A grant on a group reaches every member below it.** A grant on + `Company` applies to a member of `Company/Engineering/Backend`, because + the member's effective group set is their own groups plus all ancestors. +2. **An administrator of a group administers everything below it.** A + `Company` admin administers `Backend`; a `Sales` admin does not. + +A database is owned by one group. Administering that group means `admin` on +the database: renaming it, changing its merge strategy, granting other groups +access and deleting it. Other groups reach it only through explicit grants, +with `read`, `write` or `admin`. A user's permission on a database is the +strongest grant that reaches them. Server administrators administer +everything. + +Nothing is browsable. A user who has no permission on a database cannot see +that it exists: the blob endpoint answers `404`, not `403`. + +## Synchronization protocol + +Full endpoint reference: [PROTOCOL.md](PROTOCOL.md). The part that matters: + +``` +GET /api/v1/databases what is assigned, with version and digest +GET /api/v1/databases/{id}/blob bytes, ETag: "" +PUT /api/v1/databases/{id}/blob If-Match: "" +GET /api/v1/events SSE stream of db-updated notifications +``` + +A push always states the version it was based on. If that is still current, +the server stores version+1, publishes `db-updated` to everyone who can read +the database, and prunes old versions. If it is not current, the stored merge +strategy decides, and the rejection carries both the current version and the +strategy so the client can resolve without another round trip. + +Pushing bytes the server already holds is a no-op that answers +`{"unchanged": true}`: a save that changed nothing does not burn a version +and does not wake other clients. + +### Merge strategies + +Set per database, changed by an administrator of the owning group +(`keeporg db -id -strategy `). + +| Strategy | Server behaviour on a stale push | Client behaviour | +| --- | --- | --- | +| `server-wins` (default) | rejects with `409 conflict` | keeps the local save as `.conflict-.kdbx`, then writes the server copy | +| `client-wins` | accepts a retry carrying `If-Match: *` | retries with force; the server copy is overwritten and kept in history | +| `manual` | rejects, and refuses force | writes the server copy as `.server-v.kdbx` beside the file, touches nothing, reports a conflict once | + +A losing save is never discarded: it is always written somewhere the user can +find it. A read-only user who edits their copy gets the same treatment, with +the server copy restored. + +**There is no entry-level merge.** The server cannot perform one, because it +cannot decrypt a database. A true three-way merge belongs in a client that +already holds the master password; the strategy field is the hook for it, and +`manual` is the honest behaviour until then. KeePassXC's own "Merge from +database" on the two files is the current answer. + +## Authentication + +Three ways in, one kind of credential out: an opaque bearer token stored in +`state.json` with an expiry (90 days by default). + +- **Password** (`POST /api/v1/auth/login`), with the built-in TOTP second + factor when the account has enrolled one. The server answers + `totp_required` so a client can ask for the code without guessing. +- **Invite link.** A group administrator creates one; the invitee opens it in + a browser, picks a username and password, and lands in the invite's group + with the invite's role. +- **Browser-approved device login**, which is also how OIDC works. The client + gets a short user code, the user approves it in a browser with a password + or with single sign-on, and the client polls until a token appears. The + same flow serves a headless desktop and an Android phone. + +OIDC is implemented against the discovery document with the standard library +only. The server is a confidential client: it exchanges the authorization +code over TLS with the token endpoint and reads the identity from the +userinfo endpoint, so there is no local JWT verification to get wrong. An +identity links to an existing account by `preferred_username` or `email`, or +provisions one when `oidc.auto_create` is set. + +## Decisions worth knowing + +**No third-party Go dependencies, no cgo.** The whole server and desktop +client build with `CGO_ENABLED=0` from the standard library: SSE instead of a +WebSocket library, `crypto/pbkdf2` (new in Go 1.24) instead of an Argon2 +package, 60 lines of HMAC for TOTP, `html/template` for the three pages. +Cross-compiling to five platforms is then one `go build` each, and there is +no dependency tree to audit for a program that guards passwords. + +**A JSON document instead of SQLite.** The metadata is hundreds of records, +never thousands: users, groups, grants. One writer with atomic rename gives +durability and consistency without cgo or a migration story, and an operator +can read and back up the whole server state with `cat`. The cost is a single +node and a full rewrite per mutation. If this ever needs several servers or +tens of thousands of users, replace `internal/store` — the rest of the code +only touches it through `Read`, `Update` and the ACL functions. + +**A mirror directory instead of FUSE.** The requirement is that a KeePass +application sees a regular `.kdbx` file. A real file in a real directory is +that, on Linux, macOS, Windows and BSD alike, with no kernel module, no +WinFsp or macFUSE dependency, no cgo and nothing to break on an OS upgrade. +Local writes are atomic (`write` + `rename`), so an application never reads +half a database. The price is that synchronization happens on a save rather +than on a `write()` syscall, which is exactly the granularity KDBX has +anyway. + +**SSE instead of WebSocket.** The channel only carries server-to-client +notifications. SSE is a `GET` that survives proxies, reconnects by itself and +needs no library on any of the three clients. A subscriber that falls behind +is dropped rather than waited for; it reconnects and reconciles from scratch, +because every notification only ever triggers a full reconcile. + +**Versions, not just a digest.** Keeping the last 20 blobs makes a rejected +push recoverable, makes "who broke the database" answerable, and costs a few +megabytes. + +## Operating it + +```sh +make deb # keeporg and keeporgd packages for this arch +sudo dpkg -i dist/keeporgd_*.deb +sudo nano /etc/keeporg/server.json +sudo systemctl enable --now keeporgd +sudo journalctl -u keeporgd # the first administrator's password is logged once +``` + +On first start with an empty state the server creates the `admin` account and +an `everyone` group, and logs the generated password once. Set +`KEEPORG_ADMIN_PASSWORD` to choose it instead. + +`/etc/keeporg/server.json`: + +```json +{ + "addr": ":8765", + "data_dir": "/var/lib/keeporg", + "external_url": "https://keeporg.example.com", + "session_ttl_hours": 2160, + "max_db_size_mb": 32, + "oidc": { + "issuer": "https://id.example.com/realms/main", + "client_id": "keeporg", + "client_secret": "...", + "scopes": "openid profile email", + "auto_create": false, + "default_group_id": "" + } +} +``` + +`external_url` must be what a browser sees: invite links, device approval +pages and the OIDC redirect URI are built from it. Terminate TLS in front of +the server, or set `tls_cert` and `tls_key`. + +Back up `/var/lib/keeporg`. It is the whole server: `state.json` plus the +blobs. There is nothing else to dump. + +### Setting up a team + +```sh +keeporg login -server https://keeporg.example.com -user admin +keeporg group -create engineering -parent +keeporg db -create "Engineering" -group +keeporg invite -group -role member -uses 5 -hours 72 +keeporg grant -group -db -perm read +keeporg db -id -strategy manual +``` + +A new database has no bytes until someone saves one. Create the KDBX in your +KeePass application under the exact name the server shows, inside the mirror +directory; the client adopts it and pushes it as version 1. + +## Versioning and publishing + +`versions.mk` is the only place a version lives, one per platform plus the +Android `versionCode`. Binaries are stamped at build time through +`-ldflags -X main.Version=...`. + +```sh +make bump PLATFORM=android PART=minor # also increments versionCode +make changelog MSG="what changed" # prepends to the Debian changelog +``` + +Application identifiers follow `cc.tiredsysadmin..`: +`cc.tiredsysadmin.android.keeporg`, `.macos.keeporg`, `.linux.keeporg`, +`.windows.keeporg`, `.bsd.keeporg`, and `.linux.keeporgd` for the server +package. + +Desktop and server artifacts stay local (`dist/`). Android publishing is +manual, never automatic: + +```sh +make android # unsigned APK, named cc.tiredsysadmin.android.keeporg_.apk +make android-publish # scp to the private F-Droid unsigned and metadata directories +``` + +Then sign and index on the repository host (`fdroid publish && fdroid update`). + +## Testing + +```sh +make check # gofmt, go vet, go test ./... +make test-race # the same suite under the race detector +``` + +What the suite actually covers: + +- `internal/totp` against the RFC 6238 vectors. +- `internal/store` for grant inheritance, sibling isolation, admin + inheritance, cycle safety, update rollback and expiry. +- `internal/server` for every push verdict as a table, and an end-to-end HTTP + pass over login, stale push, forced push, idempotent push, read-only + grants, invite redemption and reuse, TOTP enrolment, device login and the + event stream. +- `internal/client` for the decision table, and for the mirror driven against + a real in-process server: adopting a local file, following a remote push, + server-wins, client-wins, manual, read-only restore, server-side rename, + and an update arriving over the event stream. + +## Known limits and the next things to do + +- **One server node.** The JSON store is single-writer by design. Horizontal + scale means replacing `internal/store`. +- **No entry-level merge.** See the strategies section. The pluggable place + is the client, which holds the key. +- **The Android module has not been compiled in this environment** (no JDK or + Android SDK was available). The Gradle wrapper is deliberately not + committed; run `gradle wrapper --gradle-version 8.9` once, then + `make android`. Expect to fix small things on the first build. +- **No key-file or Yubikey handling**, and none needed: those live in the + KeePass application. +- **Sessions are not listable or revocable individually** beyond + `keeporg logout` on the device itself. Adding `GET`/`DELETE + /api/v1/auth/sessions` is a small, well-bounded job. +- **Android does not restart its watcher at boot.** Add a + `RECEIVE_BOOT_COMPLETED` receiver if that matters more than the permission + it costs. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..0656958 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,91 @@ +# KeepOrg HTTP API + +Every `/api/` endpoint takes and returns JSON and needs +`Authorization: Bearer `, except the login, device and invite +endpoints. Errors carry `{"code": "...", "error": "..."}`; `code` is stable +and meant to be matched on, `error` is for humans. + +Three endpoints serve HTML, because a browser is the right place for them: +`/device`, `/invite/{token}` and `/oidc/callback`. + +## Authentication + +| Method | Path | Body | Answer | +| --- | --- | --- | --- | +| POST | `/api/v1/auth/login` | `{username, password, totp, label}` | `{token, expires_at, user}`; `401 totp_required` when a second factor is enrolled and missing, `401 totp_invalid`, `401 bad_credentials` | +| POST | `/api/v1/auth/logout` | – | revokes the calling token | +| GET | `/api/v1/auth/whoami` | – | the account and its group memberships | +| POST | `/api/v1/auth/totp/enroll` | – | `{secret, uri}`; not active until confirmed | +| POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` | +| POST | `/api/v1/auth/device/start` | `{label}` | `{device_code, user_code, verification_url, interval, expires_in}` | +| POST | `/api/v1/auth/device/poll` | `{device_code}` | `202 {status: "pending"}`, then `200 {token, ...}` once; `410 expired` | + +A device code is single use: the first successful poll consumes it. + +## Synchronization + +| Method | Path | Notes | +| --- | --- | --- | +| GET | `/api/v1/databases` | `{databases: [{id, name, perm, merge_strategy, version, sha256, size, updated_at, updated_by}]}` | +| GET | `/api/v1/databases/{id}/blob` | bytes; `ETag: ""`, `X-KeepOrg-SHA256`; `204` when the database has no content yet; `?version=` reads a stored older version | +| PUT | `/api/v1/databases/{id}/blob` | body is the KDBX file. `If-Match` is required | +| GET | `/api/v1/events` | SSE. Accepts `?access_token=` because an event stream cannot always set headers | + +### PUT semantics + +`If-Match` carries the version the edit was based on, or `*` to force. + +| Condition | Status | Body | +| --- | --- | --- | +| `If-Match` missing | `428 precondition_required` | – | +| version matches, bytes differ | `200` | `{version, sha256, unchanged: false}` | +| version matches, bytes identical | `200` | `{version, sha256, unchanged: true}` | +| version stale | `409 conflict` | `{current_version, merge_strategy}`, plus `ETag` and `X-KeepOrg-Strategy` | +| `If-Match: *`, strategy is `client-wins` | `200` | the server copy is replaced | +| `If-Match: *`, any other strategy | `409 force_not_allowed` | – | +| read-only access | `403 forbidden` | – | +| no access | `404 not_found` | a database you cannot read does not exist to you | +| larger than `max_db_size_mb` | `413 too_large` | – | + +`X-KeepOrg-SHA256` on a `PUT` is optional; when present it must match the +body or the request is rejected with `400 checksum_mismatch`. + +### Events + +``` +event: db-updated +data: {"type":"db-updated","database_id":"...","name":"Team","version":7,"sha256":"...","by":"alice"} + +event: db-list-changed +data: {"type":"db-list-changed","database_id":"..."} +``` + +`db-updated` follows an accepted push; `db-list-changed` follows a grant, +rename, strategy change or deletion. A comment line (`: keepalive`) arrives +every 25 seconds. Reconnect on any error and reconcile fully; events are +notifications, never state. + +## Administration + +Permission needed is in the last column. "Group admin" means administering +the group named or any group above it. + +| Method | Path | Body / query | Needs | +| --- | --- | --- | --- | +| GET | `/api/v1/groups` | – | any account; lists what you can see | +| POST | `/api/v1/groups` | `{name, parent_id}` | group admin of the parent; server admin for a root group | +| DELETE | `/api/v1/groups/{id}` | – | group admin; refuses while it has children, members or databases | +| POST | `/api/v1/groups/{id}/members` | `{username or user_id, role}` | group admin | +| DELETE | `/api/v1/groups/{id}/members/{user}` | – | group admin | +| POST | `/api/v1/databases` | `{name, owner_group_id, merge_strategy}` | group admin of the owner | +| PATCH | `/api/v1/databases/{id}` | `{name?, merge_strategy?}` | database admin | +| DELETE | `/api/v1/databases/{id}` | – | database admin; deletes every stored version | +| POST | `/api/v1/grants` | `{group_id, database_id, perm}` | database admin | +| DELETE | `/api/v1/grants` | `?group_id=&database_id=` | database admin | +| POST | `/api/v1/invites` | `{group_id, role, ttl_hours, max_uses}` | group admin | +| GET | `/api/v1/invites` | – | your own invites, and those of groups you administer | +| GET | `/api/v1/users` | – | yourself, plus members of groups you administer | +| GET | `/healthz` | – | unauthenticated liveness | + +`merge_strategy` is one of `server-wins`, `client-wins`, `manual`. `perm` is +one of `read`, `write`, `admin`. `role` is `member` or `admin`. diff --git a/internal/server/server.go b/internal/server/server.go index 6ccd1cc..3b8fcae 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -113,10 +113,10 @@ func (s *Server) routes() { // Authentication. m.HandleFunc("POST /api/v1/auth/login", s.handleLogin) - m.HandleFunc("POST /api/v1/auth/logout", s.authed(store.PermNone, s.handleLogout)) - m.HandleFunc("GET /api/v1/auth/whoami", s.authed(store.PermNone, s.handleWhoami)) - m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(store.PermNone, s.handleTOTPEnroll)) - m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(store.PermNone, s.handleTOTPConfirm)) + m.HandleFunc("POST /api/v1/auth/logout", s.authed(s.handleLogout)) + m.HandleFunc("GET /api/v1/auth/whoami", s.authed(s.handleWhoami)) + m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll)) + m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(s.handleTOTPConfirm)) m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart) m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll) @@ -129,25 +129,25 @@ func (s *Server) routes() { m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback) // Synchronization. - m.HandleFunc("GET /api/v1/databases", s.authed(store.PermNone, s.handleListDatabases)) - m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handleGetBlob)) - m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handlePutBlob)) - m.HandleFunc("GET /api/v1/events", s.authed(store.PermNone, s.handleEvents)) + m.HandleFunc("GET /api/v1/databases", s.authed(s.handleListDatabases)) + m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(s.handleGetBlob)) + m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(s.handlePutBlob)) + m.HandleFunc("GET /api/v1/events", s.authed(s.handleEvents)) // Administration. - m.HandleFunc("POST /api/v1/databases", s.authed(store.PermNone, s.handleCreateDatabase)) - m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(store.PermNone, s.handlePatchDatabase)) - m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(store.PermNone, s.handleDeleteDatabase)) - m.HandleFunc("GET /api/v1/groups", s.authed(store.PermNone, s.handleListGroups)) - m.HandleFunc("POST /api/v1/groups", s.authed(store.PermNone, s.handleCreateGroup)) - m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(store.PermNone, s.handleDeleteGroup)) - m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(store.PermNone, s.handleSetMember)) - m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(store.PermNone, s.handleRemoveMember)) - m.HandleFunc("POST /api/v1/grants", s.authed(store.PermNone, s.handleSetGrant)) - m.HandleFunc("DELETE /api/v1/grants", s.authed(store.PermNone, s.handleDeleteGrant)) - m.HandleFunc("POST /api/v1/invites", s.authed(store.PermNone, s.handleCreateInvite)) - m.HandleFunc("GET /api/v1/invites", s.authed(store.PermNone, s.handleListInvites)) - m.HandleFunc("GET /api/v1/users", s.authed(store.PermNone, s.handleListUsers)) + m.HandleFunc("POST /api/v1/databases", s.authed(s.handleCreateDatabase)) + m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(s.handlePatchDatabase)) + m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(s.handleDeleteDatabase)) + m.HandleFunc("GET /api/v1/groups", s.authed(s.handleListGroups)) + m.HandleFunc("POST /api/v1/groups", s.authed(s.handleCreateGroup)) + m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(s.handleDeleteGroup)) + m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(s.handleSetMember)) + m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(s.handleRemoveMember)) + m.HandleFunc("POST /api/v1/grants", s.authed(s.handleSetGrant)) + m.HandleFunc("DELETE /api/v1/grants", s.authed(s.handleDeleteGrant)) + m.HandleFunc("POST /api/v1/invites", s.authed(s.handleCreateInvite)) + m.HandleFunc("GET /api/v1/invites", s.authed(s.handleListInvites)) + m.HandleFunc("GET /api/v1/users", s.authed(s.handleListUsers)) } // ---- request plumbing ---- @@ -185,9 +185,9 @@ func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { type handler func(http.ResponseWriter, *http.Request, *store.User) // authed resolves the bearer token to a live, enabled user before calling fn. -// Per-object permissions are checked inside each handler, where the object is -// known; min is accepted for documentation of the minimum account level. -func (s *Server) authed(_ store.Perm, fn handler) http.HandlerFunc { +// Per-object permission is then checked inside the handler, where the object +// being acted on is known. +func (s *Server) authed(fn handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, err := s.userFromRequest(r) if err != nil { diff --git a/packaging/deb/changelog b/packaging/deb/changelog new file mode 100644 index 0000000..2cb7e9e --- /dev/null +++ b/packaging/deb/changelog @@ -0,0 +1,11 @@ +keeporg (0.1.0) unstable; urgency=medium + + * First release. + * keeporgd: synchronization server with nested group access control, + invite-link and OIDC registration, built-in TOTP second factor, + versioned KDBX blobs and server-sent update notifications. + * keeporg: desktop client that mirrors assigned databases as ordinary + .kdbx files, pushes every save and applies remote changes, with + server-wins, client-wins and manual merge strategies. + + -- tiredsysadmin Thu, 29 Oct 2026 12:00:00 +0000 diff --git a/packaging/deb/client/control.in b/packaging/deb/client/control.in new file mode 100644 index 0000000..a3b183b --- /dev/null +++ b/packaging/deb/client/control.in @@ -0,0 +1,17 @@ +Package: keeporg +Version: @VERSION@ +Section: utils +Priority: optional +Architecture: @ARCH@ +Maintainer: tiredsysadmin +Homepage: https://garage.rcmd.space/keeporg +Description: KeePass database synchronization client + KeepOrg mirrors the KDBX databases a KeepOrg server has assigned to you into + one directory as ordinary files, so KeePassXC, KeePass2 or any compatible + application opens them directly. + . + Each save is pushed to the server and changes other people push are applied + locally. The client never opens a database and never sees a master password. + . + A systemd user unit is included: enable it with + `systemctl --user enable --now keeporg`. diff --git a/packaging/deb/copyright b/packaging/deb/copyright new file mode 100644 index 0000000..cfe62be --- /dev/null +++ b/packaging/deb/copyright @@ -0,0 +1,14 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: keeporg +Source: https://garage.rcmd.space/keeporg + +Files: * +Copyright: 2026 tiredsysadmin +License: AGPL-3.0-or-later + This program is free software: you can redistribute it and/or modify it under + the terms of the GNU Affero General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any + later version. + . + On Debian systems the full text is in + /usr/share/common-licenses/AGPL-3. diff --git a/packaging/deb/server/control.in b/packaging/deb/server/control.in new file mode 100644 index 0000000..81d4023 --- /dev/null +++ b/packaging/deb/server/control.in @@ -0,0 +1,19 @@ +Package: keeporgd +Version: @VERSION@ +Section: net +Priority: optional +Architecture: @ARCH@ +Maintainer: tiredsysadmin +Homepage: https://garage.rcmd.space/keeporg +Depends: adduser +Description: KeePass database synchronization server + KeepOrg stores KDBX files as opaque versioned blobs and arbitrates concurrent + writes between clients. It hosts many databases, controls access through a + tree of user groups, and accepts registrations by invite link or OIDC. + . + It never opens a database and cannot read one: master passwords stay on the + clients. + . + The service runs as the keeporg user with its data in /var/lib/keeporg and + its configuration in /etc/keeporg/server.json. Terminate TLS in front of it + with nginx or caddy, or point tls_cert and tls_key at a certificate. diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in new file mode 100644 index 0000000..c91f18f --- /dev/null +++ b/packaging/macos/Info.plist.in @@ -0,0 +1,27 @@ + + + + + CFBundleName + KeepOrg + CFBundleDisplayName + KeepOrg + CFBundleIdentifier + @APPID@ + CFBundleVersion + @VERSION@ + CFBundleShortVersionString + @VERSION@ + CFBundlePackageType + APPL + CFBundleExecutable + KeepOrg + LSMinimumSystemVersion + 11.0 + + LSUIElement + + NSHighResolutionCapable + + + diff --git a/packaging/macos/launch-agent.plist.in b/packaging/macos/launch-agent.plist.in new file mode 100644 index 0000000..aa64c3e --- /dev/null +++ b/packaging/macos/launch-agent.plist.in @@ -0,0 +1,22 @@ + + + + + + Label + @APPID@ + ProgramArguments + + /Applications/KeepOrg.app/Contents/MacOS/keeporg + run + + RunAtLoad + + KeepAlive + + StandardErrorPath + /tmp/keeporg.log + + diff --git a/packaging/macos/launcher.sh b/packaging/macos/launcher.sh new file mode 100644 index 0000000..703be2e --- /dev/null +++ b/packaging/macos/launcher.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Bundle executable: starts the synchronization agent. If this device has not +# been signed in yet, open Terminal so the user can do it. +set -eu +here=$(cd "$(dirname "$0")" && pwd) +keeporg="$here/keeporg" +config="$HOME/Library/Application Support/keeporg/config.json" + +if [ ! -f "$config" ]; then + osascript -e "tell application \"Terminal\" to do script \"'$keeporg' login -server https://keeporg.example.com -user \$USER\"" \ + -e 'tell application "Terminal" to activate' + exit 0 +fi +exec "$keeporg" run diff --git a/packaging/systemd/keeporg.service b/packaging/systemd/keeporg.service new file mode 100644 index 0000000..9169532 --- /dev/null +++ b/packaging/systemd/keeporg.service @@ -0,0 +1,16 @@ +[Unit] +Description=KeepOrg KDBX synchronization agent +Documentation=https://garage.rcmd.space/keeporg +After=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/keeporg run +Restart=on-failure +RestartSec=10 +# The agent only ever touches the mirror directory and the config file. +NoNewPrivileges=yes +PrivateTmp=yes + +[Install] +WantedBy=default.target diff --git a/packaging/systemd/keeporgd.service b/packaging/systemd/keeporgd.service new file mode 100644 index 0000000..0b65614 --- /dev/null +++ b/packaging/systemd/keeporgd.service @@ -0,0 +1,38 @@ +[Unit] +Description=KeepOrg KDBX synchronization server +Documentation=https://garage.rcmd.space/keeporg +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=keeporg +Group=keeporg +ExecStart=/usr/bin/keeporgd -config /etc/keeporg/server.json +Restart=on-failure +RestartSec=5 +StateDirectory=keeporg +StateDirectoryMode=0700 +ConfigurationDirectory=keeporg + +# The server reads one config file and writes one data directory. +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +ReadWritePaths=/var/lib/keeporg +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +CapabilityBoundingSet= +AmbientCapabilities= + +[Install] +WantedBy=multi-user.target diff --git a/packaging/windows/README.txt b/packaging/windows/README.txt new file mode 100644 index 0000000..bf03811 --- /dev/null +++ b/packaging/windows/README.txt @@ -0,0 +1,30 @@ +KeepOrg for Windows +=================== + +keeporg.exe is the synchronization agent and the administration command. + +First run +--------- + keeporg.exe login -server https://keeporg.example.com -user you + keeporg.exe run + +`login` stores its configuration in +%AppData%\keeporg\config.json and mirrors your databases into +%UserProfile%\KeepOrg by default. Pass -dir to choose another directory. + +Open the .kdbx files in that directory with KeePass, KeePassXC or any +compatible application. Every save is pushed to the server, and changes made +by other people are applied to the files. + +Keeping it running +------------------ +Import keeporg-task.xml into Task Scheduler to start the agent at logon: + + schtasks /create /tn KeepOrg /xml keeporg-task.xml + +Conflicts +--------- +If someone else saved first, your save is kept beside the file as +.conflict-.kdbx and the server copy is restored. Open both +in KeePass and merge them, or ask a group administrator to switch the +database to another merge strategy. diff --git a/packaging/windows/keeporg-task.xml b/packaging/windows/keeporg-task.xml new file mode 100644 index 0000000..1142ad3 --- /dev/null +++ b/packaging/windows/keeporg-task.xml @@ -0,0 +1,34 @@ + + + + KeepOrg KDBX synchronization agent + \KeepOrg + + + + true + + + + + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + PT0S + + PT1M + 3 + + + + + %LOCALAPPDATA%\Programs\KeepOrg\keeporg.exe + run -quiet + + + diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh new file mode 100755 index 0000000..9068e95 --- /dev/null +++ b/scripts/build-deb.sh @@ -0,0 +1,83 @@ +#!/bin/sh +# Build one Debian package. +# build-deb.sh +set -eu + +kind=$1 +version=$2 +arch=$3 +out=$4 + +root=$(cd "$(dirname "$0")/.." && pwd) +stage=$root/build/deb-$kind-$arch +rm -rf "$stage" + +case $kind in +client) + pkg=keeporg + binary=keeporg + mkdir -p "$stage/usr/bin" "$stage/usr/lib/systemd/user" + install -m 0755 "$root/build/linux-$arch/keeporg" "$stage/usr/bin/keeporg" + install -m 0644 "$root/packaging/systemd/keeporg.service" \ + "$stage/usr/lib/systemd/user/keeporg.service" + ;; +server) + pkg=keeporgd + binary=keeporgd + mkdir -p "$stage/usr/bin" "$stage/usr/lib/systemd/system" "$stage/etc/keeporg" + install -m 0755 "$root/build/linux-$arch/keeporgd" "$stage/usr/bin/keeporgd" + install -m 0644 "$root/packaging/systemd/keeporgd.service" \ + "$stage/usr/lib/systemd/system/keeporgd.service" + "$root/build/linux-$arch/keeporgd" -write-config "$stage/etc/keeporg/server.json" >/dev/null + chmod 0640 "$stage/etc/keeporg/server.json" + ;; +*) + echo "build-deb.sh: unknown package kind $kind" >&2 + exit 2 + ;; +esac + +mkdir -p "$stage/DEBIAN" "$stage/usr/share/doc/$pkg" +sed -e "s/@VERSION@/$version/" -e "s/@ARCH@/$arch/" \ + "$root/packaging/deb/$kind/control.in" > "$stage/DEBIAN/control" +install -m 0644 "$root/packaging/deb/copyright" "$stage/usr/share/doc/$pkg/copyright" +gzip -9nc "$root/packaging/deb/changelog" > "$stage/usr/share/doc/$pkg/changelog.Debian.gz" +chmod 0644 "$stage/usr/share/doc/$pkg/changelog.Debian.gz" + +if [ "$kind" = server ]; then + cat > "$stage/DEBIAN/conffiles" <<'CONF' +/etc/keeporg/server.json +CONF + cat > "$stage/DEBIAN/postinst" <<'POST' +#!/bin/sh +set -e +if [ "$1" = configure ]; then + if ! getent passwd keeporg >/dev/null; then + adduser --system --group --home /var/lib/keeporg \ + --gecos "KeepOrg synchronization server" keeporg + fi + install -d -o keeporg -g keeporg -m 0700 /var/lib/keeporg + chgrp keeporg /etc/keeporg/server.json || true + chmod 0640 /etc/keeporg/server.json || true + if [ -d /run/systemd/system ]; then + systemctl daemon-reload || true + echo "keeporgd: edit /etc/keeporg/server.json, then" \ + "systemctl enable --now keeporgd" >&2 + fi +fi +exit 0 +POST + cat > "$stage/DEBIAN/prerm" <<'PRERM' +#!/bin/sh +set -e +if [ "$1" = remove ] && [ -d /run/systemd/system ]; then + systemctl stop keeporgd || true +fi +exit 0 +PRERM + chmod 0755 "$stage/DEBIAN/postinst" "$stage/DEBIAN/prerm" +fi + +mkdir -p "$(dirname "$out")" +fakeroot dpkg-deb --root-owner-group --build "$stage" "$out" >/dev/null +printf 'built %s (%s %s %s)\n' "$out" "$binary" "$version" "$arch" diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh new file mode 100755 index 0000000..c79fd9a --- /dev/null +++ b/scripts/build-dmg.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Build the macOS disk image: an application bundle beside a symlink to +# /Applications, which is what a user expects to drag. +# build-dmg.sh +set -eu + +version=$1 +arch=$2 +appid=$3 +out=$4 + +if [ "$(uname -s)" != Darwin ]; then + echo "build-dmg.sh: a DMG can only be built on macOS (hdiutil is required)" >&2 + echo "the binary itself cross-compiles: make build/darwin-$arch/keeporg" >&2 + exit 1 +fi + +root=$(cd "$(dirname "$0")/.." && pwd) +stage=$root/build/dmg-$arch +app=$stage/KeepOrg.app +rm -rf "$stage" +mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" + +install -m 0755 "$root/build/darwin-$arch/keeporg" "$app/Contents/MacOS/keeporg" +sed -e "s/@VERSION@/$version/" -e "s/@APPID@/$appid/" \ + "$root/packaging/macos/Info.plist.in" > "$app/Contents/Info.plist" +install -m 0755 "$root/packaging/macos/launcher.sh" "$app/Contents/MacOS/KeepOrg" +sed -e "s/@APPID@/$appid/" "$root/packaging/macos/launch-agent.plist.in" \ + > "$app/Contents/Resources/$appid.plist" +cp "$root/docs/HANDOVER.md" "$stage/Read me first.md" 2>/dev/null || true + +# The symlink every macOS DMG carries, so installing is one drag. +ln -s /Applications "$stage/Applications" + +mkdir -p "$(dirname "$out")" +rm -f "$out" +hdiutil create -volname "KeepOrg $version" -srcfolder "$stage" \ + -ov -fs HFS+ -format UDZO "$out" >/dev/null +printf 'built %s\n' "$out" diff --git a/scripts/bump.sh b/scripts/bump.sh new file mode 100755 index 0000000..d9842d6 --- /dev/null +++ b/scripts/bump.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# Bump one platform's semantic version in versions.mk. +# bump.sh +# Bumping android also increments its monotonic versionCode. +set -eu + +platform=${1:-} +part=${2:-patch} +root=$(cd "$(dirname "$0")/.." && pwd) +file=$root/versions.mk + +case $platform in +server|linux|macos|windows|bsd|android|all) ;; +*) + echo "usage: make bump PLATFORM= PART=" >&2 + exit 2 + ;; +esac +case $part in +major|minor|patch) ;; +*) + echo "bump.sh: PART must be major, minor or patch" >&2 + exit 2 + ;; +esac + +bump_one() { + key=VERSION_$(echo "$1" | tr 'a-z' 'A-Z') + current=$(sed -n "s/^$key[[:space:]]*:=[[:space:]]*\([0-9.]*\).*/\1/p" "$file") + [ -n "$current" ] || { echo "bump.sh: $key not found in versions.mk" >&2; exit 1; } + major=$(echo "$current" | cut -d. -f1) + minor=$(echo "$current" | cut -d. -f2) + patch=$(echo "$current" | cut -d. -f3) + case $part in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + esac + next=$major.$minor.$patch + sed -i.bak "s/^$key[[:space:]]*:=.*/$(printf '%-16s := %s' "$key" "$next")/" "$file" + printf '%s %s -> %s\n' "$1" "$current" "$next" + if [ "$1" = android ]; then + code=$(sed -n 's/^ANDROID_VERSION_CODE[[:space:]]*:=[[:space:]]*\([0-9]*\).*/\1/p' "$file") + sed -i.bak "s/^ANDROID_VERSION_CODE[[:space:]]*:=.*/ANDROID_VERSION_CODE := $((code + 1))/" "$file" + printf 'android versionCode %s -> %s\n' "$code" "$((code + 1))" + fi +} + +if [ "$platform" = all ]; then + for p in server linux macos windows bsd android; do bump_one "$p"; done +else + bump_one "$platform" +fi +rm -f "$file.bak" diff --git a/scripts/changelog.sh b/scripts/changelog.sh new file mode 100755 index 0000000..911ba24 --- /dev/null +++ b/scripts/changelog.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Prepend an entry to the Debian changelog. +# changelog.sh "what changed" +set -eu + +version=${1:-} +message=${2:-} +root=$(cd "$(dirname "$0")/.." && pwd) +file=$root/packaging/deb/changelog + +if [ -z "$version" ] || [ -z "$message" ]; then + echo 'usage: make changelog MSG="what changed"' >&2 + exit 2 +fi + +author=${DEBFULLNAME:-tiredsysadmin} +email=${DEBEMAIL:-rcmd@garage.rcmd.space} +tmp=$(mktemp) +{ + printf 'keeporg (%s) unstable; urgency=medium\n\n' "$version" + printf ' * %s\n\n' "$message" + printf ' -- %s <%s> %s\n\n' "$author" "$email" "$(date -R)" + cat "$file" +} > "$tmp" +mv "$tmp" "$file" +printf 'added %s to %s\n' "$version" "$file"