Keep server data in an encrypted database, and harden every part

Storage. The server keeps its data in SQLite (a file in the data
directory, the default) or PostgreSQL (database, KEEPORG_DATABASE_URL or
-database). Every row, the KDBX versions included, is sealed with
AES-256-GCM under a key derived from the master key and named by an HMAC.
The master key is required to start and a different one is refused. A data
directory from 0.10 or earlier is imported on first start. Several servers
may share one PostgreSQL database. Go goes to 1.26 for the pure-Go
drivers (modernc.org/sqlite, jackc/pgx).

Hardening, after an audit of the server, both clients and the deployment:
- Single sign-on approves a device only after an explicit Approve in the
  browser that started it, bound by a cookie and PKCE; identities are never
  linked by username, by verified email only when allowed, never to admins.
- Sign-in attempts are limited per address and per account, passwords are
  checked outside the store lock with uniform timing, one-time codes work
  once, and starting two-factor needs the password.
- Merge vaults bound KDF parameters, payload size and XML shape, check
  pushes before storing them, and run at most two merges at once. A version
  is written in the same transaction as its metadata.
- Tokens in URLs only on the event stream, which ends on revocation; CSP,
  frame-ancestors, no-store and HSTS; sign-in CSRF; 404 for databases
  without access; new trusted_proxies.
- Clients open only the server's approval page, keep case-variant and
  retired file names apart, never overwrite a save made during a pull,
  require https except locally, send the stream token in a header, bound
  their reads and escape notifications and terminal output.
- The .deb creates the master key, systemd is sandboxed further, nginx sets
  modern TLS and HSTS, and CI deploys from main only.

Server 0.12.0, desktop 0.6.0, Android 0.4.0 (versionCode 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-11-02 15:38:32 +01:00
co-authored by Claude Opus 5.5
parent f8f634fbf7
commit 4ffc434b1b
77 changed files with 4192 additions and 776 deletions
@@ -132,7 +132,7 @@ class Api(baseUrl: String, var token: String?) {
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 data = c.inputStream.use { it.readAllBytesCompat(MAX_PAYLOAD) }
val version = c.getHeaderField("ETag")?.trim('"')?.toLongOrNull() ?: 0L
return Pair(data, version)
} finally {
@@ -150,7 +150,7 @@ class Api(baseUrl: String, var token: String?) {
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())
val o = JSONObject(c.inputStream.use { it.readAllBytesCompat(MAX_JSON) }.decodeToString())
return PushResult(
o.getLong("version"),
o.optString("sha256"),
@@ -169,10 +169,12 @@ class Api(baseUrl: String, var token: String?) {
* 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
// The token goes in the header, as everywhere else: in the URL it
// would land in proxy logs.
val c = URL("$base/api/v1/events").openConnection() as HttpURLConnection
c.requestMethod = "GET"
c.setRequestProperty("Accept", "text/event-stream")
token?.takeIf { it.isNotEmpty() }?.let { c.setRequestProperty("Authorization", "Bearer $it") }
c.connectTimeout = 15_000
// The server sends a keepalive comment every 25 seconds.
c.readTimeout = 70_000
@@ -182,6 +184,11 @@ class Api(baseUrl: String, var token: String?) {
var data = StringBuilder()
while (true) {
val line = r.readLine() ?: return
// Events are a few hundred bytes; a stream that keeps
// growing one is not a KeepOrg server.
if (line.length > MAX_JSON || data.length > MAX_JSON) {
throw IOException("event stream: an event exceeds 1 MiB")
}
when {
line.isEmpty() -> {
if (data.isNotEmpty()) {
@@ -231,7 +238,7 @@ class Api(baseUrl: String, var token: String?) {
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()
val text = c.inputStream.use { it.readAllBytesCompat(MAX_JSON) }.decodeToString()
return if (text.isBlank()) JSONObject() else JSONObject(text)
} finally {
c.disconnect()
@@ -240,7 +247,7 @@ class Api(baseUrl: String, var token: String?) {
private fun errorOf(c: HttpURLConnection, status: Int): ApiException {
val text = try {
(c.errorStream ?: c.inputStream)?.use { it.readAllBytesCompat() }?.decodeToString() ?: ""
(c.errorStream ?: c.inputStream)?.use { it.readAllBytesCompat(MAX_JSON) }?.decodeToString() ?: ""
} catch (_: IOException) {
""
}
@@ -257,13 +264,23 @@ class Api(baseUrl: String, var token: String?) {
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 {
/** One database download, at most; the server's own limit is far below. */
internal const val MAX_PAYLOAD = 256 * 1024 * 1024
/** One JSON answer or event, at most. */
internal const val MAX_JSON = 1024 * 1024
/**
* readAllBytes() needs API 33; this works on every supported release, and
* refuses to hold more than [limit] bytes of whatever the server sends.
*/
internal fun InputStream.readAllBytesCompat(limit: Int): ByteArray {
val out = ByteArrayOutputStream()
val buf = ByteArray(16 * 1024)
while (true) {
val n = read(buf)
if (n < 0) break
if (out.size() + n > limit) throw IOException("the server sent more than ${limit shr 20} MiB")
out.write(buf, 0, n)
}
return out.toByteArray()
@@ -97,9 +97,13 @@ class KeepOrgProvider : DocumentsProvider() {
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.
// The application is about to save. Until it closes the file the
// mirror leaves it alone — a pass would read half a save, or rename
// a pulled copy over it — and the close triggers the next pass,
// when the new bytes are complete on disk.
writers.merge(file.name, 1, Int::plus)
return ParcelFileDescriptor.open(file, access, closeHandler) {
writers.computeIfPresent(file.name) { _, n -> if (n > 1) n - 1 else null }
Syncer.request(ctx(), "saved $documentId")
}
}
@@ -154,10 +158,14 @@ class KeepOrgProvider : DocumentsProvider() {
}
private fun fileOf(documentId: String): File {
if (documentId.contains('/') || documentId.contains("..")) {
if (documentId.contains('/') || documentId.contains('\u0000')) {
throw FileNotFoundException("bad document id")
}
val f = File(mirrorDir(), documentId)
val dir = mirrorDir().canonicalFile
val f = File(dir, documentId)
// The canonical parent must be the mirror itself: no "..", no link
// out of it.
if (f.canonicalFile.parentFile != dir) throw FileNotFoundException("bad document id")
if (!f.isFile) throw FileNotFoundException(documentId)
return f
}
@@ -166,6 +174,11 @@ class KeepOrgProvider : DocumentsProvider() {
name.contains(".conflict-") || name.contains(".server-v")
companion object {
/** Files a KeePass application holds open for writing, by name. */
private val writers = java.util.concurrent.ConcurrentHashMap<String, Int>()
fun isOpenForWrite(name: String): Boolean = writers.containsKey(name)
const val AUTHORITY = "cc.tiredsysadmin.android.keeporg.documents"
const val ROOT_ID = "keeporg"
const val ROOT_DOC = "root"
@@ -123,6 +123,12 @@ class LoginActivity : Activity() {
System.currentTimeMillis() + start.expiresIn * 1000L,
)
collectPendingSignIn(now = false)
// Only the server's own approval page: a compromised server
// must not choose what this phone opens.
if (!isServerPage(api.serverUrl, start.verificationUrl)) {
failed(IOException("the server asked to open a page that is not its own"))
return@fold
}
runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(start.verificationUrl)))
}
@@ -215,6 +221,15 @@ class LoginActivity : Activity() {
}
companion object {
/** Whether [page] is [server]'s device approval page. */
fun isServerPage(server: String, page: String): Boolean {
val s = runCatching { java.net.URI(server) }.getOrNull() ?: return false
val p = runCatching { java.net.URI(page) }.getOrNull() ?: return false
return p.scheme == s.scheme && p.host.equals(s.host, ignoreCase = true) &&
p.port == s.port && p.userInfo == null &&
p.path == (s.path ?: "").trimEnd('/') + "/device"
}
/** Where the approval page links back to; see the manifest. */
const val RETURN_URL = "keeporg://device-approved"
}
@@ -43,11 +43,10 @@ class Mirror(private val context: Context, private val api: Api, private val pre
}
}
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)
}
// A database no longer assigned stops being synchronized. Its file
// stays — deleting someone's password database is not this app's
// decision — and its name stays retired.
prefs.retiredIds = (prefs.retiredIds + (prefs.trackedIds - seen)) - seen
prefs.trackedIds = seen
notifyPicker()
return if (firstError == null) Result.success(Unit) else Result.failure(firstError)
@@ -56,6 +55,13 @@ class Mirror(private val context: Context, private val api: Api, private val pre
/** Decides and applies the one action this database needs now. */
private fun reconcile(db: Db) {
val st = trackedState(db)
// A KeePass application is writing it right now: reading it would
// see half a save, and replacing it would lose the rest. The close
// of that descriptor triggers the next pass.
if (KeepOrgProvider.isOpenForWrite(st.file)) {
prefs.putState(db.id, st)
return
}
val file = File(dir, st.file)
val localExists = file.isFile
val local = if (localExists) file.readBytes() else ByteArray(0)
@@ -77,7 +83,7 @@ class Mirror(private val context: Context, private val api: Api, private val pre
when (action) {
Action.NOTHING -> if (prefs.status(db.id).isEmpty()) prefs.putStatus(db.id, "in sync")
Action.PULL -> pull(db, st, file)
Action.PULL -> pull(db, st, file, localSha)
Action.PUSH, Action.PUSH_FOR_MERGE -> push(db, st, file, local, force = false)
@@ -85,7 +91,7 @@ class Mirror(private val context: Context, private val api: Api, private val pre
Action.SERVER_WINS, Action.RESTORE -> {
val kept = keepConflictCopy(file, local)
pull(db, st, file)
pull(db, st, file, localSha)
val why = if (action == Action.RESTORE)
"read-only access: your save was kept as ${kept.name}"
else
@@ -104,9 +110,19 @@ class Mirror(private val context: Context, private val api: Api, private val pre
prefs.putState(db.id, st)
}
private fun pull(db: Db, st: Prefs.FileState, file: File) {
/**
* Replaces the local file with the server copy. [expectSha] is the digest
* of the file the decision was made on ("" for none): if the file is
* different now, it was saved in the meantime, and replacing it would
* lose that save without a trace.
*/
private fun pull(db: Db, st: Prefs.FileState, file: File, expectSha: String) {
val (data, version) = api.pull(db.id)
if (version == 0L || data.isEmpty()) return
val nowSha = if (file.isFile) digest(file.readBytes()) else ""
if (nowSha != expectSha) {
throw IOException("the file was saved during synchronization; trying again")
}
writeAtomic(file, data)
st.version = version
st.sha256 = digest(data)
@@ -120,12 +136,12 @@ class Mirror(private val context: Context, private val api: Api, private val pre
st.sha256 = res.sha256
if (res.merged) {
// The result is neither copy: take what the server holds.
pull(db, st, file)
pull(db, st, file, digest(data))
prefs.putStatus(db.id, "merged: ${res.mergeSummary}")
return
}
if (res.sha256 != digest(data)) {
pull(db, st, file)
pull(db, st, file, digest(data))
return
}
prefs.putStatus(db.id, if (res.unchanged) "in sync" else "pushed version ${res.version}")
@@ -138,12 +154,12 @@ class Mirror(private val context: Context, private val api: Api, private val pre
return
}
val kept = keepConflictCopy(file, data)
pull(db, st, file)
pull(db, st, file, digest(data))
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)
pull(db, st, file, digest(data))
prefs.putStatus(db.id, "read-only access: your save was kept as ${kept.name}")
}
else -> throw e
@@ -154,6 +170,8 @@ class Mirror(private val context: Context, private val api: Api, private val pre
/** 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)
// Access came back: the file and what was last agreed still hold.
if (db.id in prefs.retiredIds) prefs.retiredIds = prefs.retiredIds - db.id
if (st.file.isEmpty()) {
st.name = db.name
st.file = fileName(db)
@@ -163,6 +181,13 @@ class Mirror(private val context: Context, private val api: Api, private val pre
val want = fileName(db)
val old = File(dir, st.file)
if (old.isFile && want != st.file) {
// Never onto a file that is already there: renameTo would
// replace it, and it may be another vault.
if (File(dir, want).exists()) {
Log.w(TAG, "not renaming ${st.file} to $want, which already exists")
st.name = db.name
return st
}
if (!old.renameTo(File(dir, want))) {
Log.w(TAG, "could not rename ${st.file} to $want")
return st
@@ -174,10 +199,12 @@ class Mirror(private val context: Context, private val api: Api, private val pre
return st
}
/** Names compare without regard to case, as in the desktop client. */
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 }
val taken = (prefs.trackedIds + prefs.retiredIds)
.any { it != db.id && prefs.state(it).file.equals(name, ignoreCase = true) }
return if (taken) "$base-${db.id.take(6)}.kdbx" else name
}
@@ -248,10 +275,40 @@ class Mirror(private val context: Context, private val api: Api, private val pre
}
}
fun sanitize(name: String): String = buildString {
for (ch in name.trim()) {
append(if (ch.isLetterOrDigit() || ch in "-_. ") ch else '-')
/**
* Keeps a database name usable as a file name and out of the names
* this app makes for itself. The same rules as the desktop client's
* sanitize, character for character.
*/
fun sanitize(name: String): String {
var out = buildString {
for (ch in name.trim()) {
val ok = ch in 'a'..'z' || ch in 'A'..'Z' || ch in '0'..'9' || ch in "-_. "
append(if (ok) ch else '-')
}
}
}.removeSuffix(".kdbx").trim(' ', '.', '-')
while (out.contains("..")) out = out.replace("..", ".")
out = out.removeSuffix(".kdbx").trim(' ', '.', '-')
for (reserved in listOf(".server-v", ".conflict-", ".keeporg")) {
out = undot(out, reserved)
}
val stem = out.substringBefore('.').uppercase(Locale.ROOT)
if (stem in WINDOWS_DEVICES) out += "-db"
return out
}
private val WINDOWS_DEVICES = setOf("CON", "PRN", "AUX", "NUL") +
(1..9).flatMap { listOf("COM$it", "LPT$it") }
/** Turns the dot of each case-insensitive [marker] into a dash. */
private fun undot(s: String, marker: String): String {
val out = StringBuilder(s)
var i = s.indexOf(marker, ignoreCase = true)
while (i >= 0) {
out.setCharAt(i, '-')
i = s.indexOf(marker, i + marker.length, ignoreCase = true)
}
return out.toString()
}
}
}
@@ -105,6 +105,15 @@ class Prefs(context: Context) {
get() = sp.getStringSet("tracked", emptySet()) ?: emptySet()
set(v) = sp.edit().putStringSet("tracked", v).apply()
/**
* Databases this device lost access to. Their state is kept, so their
* file names stay taken: a database created later under the same name
* must not adopt, and upload, a file that belonged to another.
*/
var retiredIds: Set<String>
get() = sp.getStringSet("retired", emptySet()) ?: emptySet()
set(v) = sp.edit().putStringSet("retired", 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()