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:
co-authored by
Claude Opus 5.5
parent
f8f634fbf7
commit
4ffc434b1b
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user