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
+2 -1
View File
@@ -1,6 +1,7 @@
# The image is built from go.mod, cmd/keeporgd and internal only. Everything # The image is built from go.mod, go.sum, cmd/keeporgd and internal only. Everything
# else just makes the build context bigger. # else just makes the build context bigger.
* *
!go.mod !go.mod
!go.sum
!cmd/keeporgd/ !cmd/keeporgd/
!internal/ !internal/
+3
View File
@@ -9,6 +9,9 @@ when:
- 'Dockerfile' - 'Dockerfile'
- '**/*.go' - '**/*.go'
event: [push, cron, manual] event: [push, cron, manual]
# These steps run as root on the build and production hosts: only the
# main branch may reach them.
branch: main
labels: labels:
role: production role: production
skip_clone: true skip_clone: true
+3
View File
@@ -9,6 +9,9 @@ when:
- 'Dockerfile' - 'Dockerfile'
- '**/*.go' - '**/*.go'
event: [push, cron, manual] event: [push, cron, manual]
# These steps run as root on the build and production hosts: only the
# main branch may reach them.
branch: main
labels: labels:
role: homeserver role: homeserver
depends_on: depends_on:
+16 -13
View File
@@ -2,9 +2,10 @@
# KeepOrg synchronization server. # KeepOrg synchronization server.
# #
# The server is a static binary with no third-party dependencies and no cgo, # The server is a static binary with no cgo — its only third-party code is
# so the runtime image carries the binary, a certificate bundle for talking # the two pure-Go database drivers — so the runtime image carries the
# to an OIDC provider, and nothing else. There is no shell in it: the binary # binary, a certificate bundle for talking to an OIDC provider or a
# PostgreSQL server over TLS, and nothing else. There is no shell in it: the binary
# probes its own health. # probes its own health.
# #
# podman build --format docker -t keeporgd:<version> --build-arg VERSION=<version> . # podman build --format docker -t keeporgd:<version> --build-arg VERSION=<version> .
@@ -15,14 +16,15 @@
# #
# or `make docker`, which takes the version from versions.mk. # or `make docker`, which takes the version from versions.mk.
FROM golang:1.24-alpine AS build FROM golang:1.26-alpine AS build
ARG VERSION=dev ARG VERSION=dev
WORKDIR /src WORKDIR /src
# Only what the server is built from, so a change to the clients, the # Only what the server is built from, so a change to the clients, the
# Android app or the docs does not invalidate this layer. # Android app or the docs does not invalidate this layer.
COPY go.mod ./ COPY go.mod go.sum ./
RUN go mod download
COPY cmd/keeporgd ./cmd/keeporgd COPY cmd/keeporgd ./cmd/keeporgd
COPY internal ./internal COPY internal ./internal
@@ -46,14 +48,15 @@ LABEL org.opencontainers.image.title="KeepOrg server" \
org.opencontainers.image.licenses="AGPL-3.0-or-later" \ org.opencontainers.image.licenses="AGPL-3.0-or-later" \
cc.tiredsysadmin.application="cc.tiredsysadmin.linux.keeporgd" cc.tiredsysadmin.application="cc.tiredsysadmin.linux.keeporgd"
# Needed to reach an OIDC provider over TLS. Nothing else from the builder # Needed to reach an OIDC provider, or PostgreSQL, over TLS. Nothing else from the builder
# comes along. # comes along.
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
# The data directory holds state.json and the KDBX blobs. The configuration # The data directory holds the SQLite database, unless KEEPORG_DATABASE_URL
# directory holds server.json and, unless the key is supplied through # points at PostgreSQL. Every row in it is sealed under the master key. The
# KEEPORG_MASTER_KEY, the master key that unwraps the vault keys. They are # configuration directory holds server.json and, unless the key is supplied
# separate on purpose: whoever holds both can read every merge vault. # otherwise, that master key. They are separate on purpose: whoever holds
# both can read everything, and either alone is useless.
# #
# Give the master key somewhere durable, one of these three ways: # Give the master key somewhere durable, one of these three ways:
# #
@@ -67,9 +70,9 @@ COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certifica
# a secret that failed to mount is reported instead of being replaced. # a secret that failed to mount is reported instead of being replaced.
# #
# With none of the three, the server mints a key into the container's own # With none of the three, the server mints a key into the container's own
# layer on first start and loses it on the next `run`. It says so loudly at # layer on first start and loses it on the next `run`, after which it
# startup, and merging is all that stops working: clients still open their # refuses to start: the database does not open with a new key. Nothing is
# vaults. # overwritten; mount the original key and it starts.
COPY --from=build --chown=65532:65532 /out/var/lib/keeporg /var/lib/keeporg COPY --from=build --chown=65532:65532 /out/var/lib/keeporg /var/lib/keeporg
COPY --from=build --chown=65532:65532 /out/etc/keeporg /etc/keeporg COPY --from=build --chown=65532:65532 /out/etc/keeporg /etc/keeporg
VOLUME ["/var/lib/keeporg", "/etc/keeporg"] VOLUME ["/var/lib/keeporg", "/etc/keeporg"]
+15 -10
View File
@@ -25,11 +25,12 @@ keep their key off the server entirely, exactly as before.
- **Android client**: the same, published to every file picker as a storage - **Android client**: the same, published to every file picker as a storage
provider. provider.
No FUSE, and the server has no third-party dependencies and no cgo. The No FUSE and no cgo. The server keeps everything in SQLite or PostgreSQL,
KDBX 4 codec, including ChaCha20, BLAKE2b and Argon2, is part of the every row sealed under a master key the database never sees, and its only
repository and is tested against both the relevant RFCs and a real third-party code is the two pure-Go database drivers. The KDBX 4 codec,
KeePassXC. The desktop client's one dependency is `fyne.io/systray`, for the including ChaCha20, BLAKE2b and Argon2, is part of the repository and is
tray icon. tested against both the relevant RFCs and a real KeePassXC. The desktop
client's one dependency is `fyne.io/systray`, for the tray icon.
## Quick start ## Quick start
@@ -97,7 +98,7 @@ Or open `http://127.0.0.1:8765/web` and create the vault from the browser.
```sh ```sh
make deb # Debian packages for the client and the server make deb # Debian packages for the client and the server
make docker # server container image, scratch-based, about 9 MB make docker # server container image, scratch-based, about 25 MB
make dmg # macOS disk image with an /Applications symlink (on macOS) make dmg # macOS disk image with an /Applications symlink (on macOS)
make windows # Windows zip: keeporg-tray.exe, keeporg.exe and a Task Scheduler job make windows # Windows zip: keeporg-tray.exe, keeporg.exe and a Task Scheduler job
make android # unsigned release APK (needs the Android SDK) make android # unsigned release APK (needs the Android SDK)
@@ -117,9 +118,11 @@ podman run -d -p 8765:8765 \
keeporgd:latest -addr :8765 -data /var/lib/keeporg -url https://keeporg.example.com keeporgd:latest -addr :8765 -data /var/lib/keeporg -url https://keeporg.example.com
``` ```
Keep that master key: it unwraps the vault keys, and without it the server Keep that master key: everything in the database is encrypted under it,
can no longer merge. `KEEPORG_MASTER_KEY` takes the base64 key directly if and the server does not start without it. `KEEPORG_MASTER_KEY` takes the
your secret store injects variables instead of files. See base64 key directly if your secret store injects variables instead of
files. The data goes to a SQLite file on the volume; to use PostgreSQL
instead, add `-e KEEPORG_DATABASE_URL=postgres://keeporg:…@db/keeporg`. See
[packaging/docker/compose.yaml](packaging/docker/compose.yaml). [packaging/docker/compose.yaml](packaging/docker/compose.yaml).
Server environment variables: Server environment variables:
@@ -127,8 +130,10 @@ Server environment variables:
| Variable | Meaning | | Variable | Meaning |
| --- | --- | | --- | --- |
| `KEEPORG_MASTER_KEY` | the master key, base64; overrides the other two sources | | `KEEPORG_MASTER_KEY` | the master key, base64; overrides the other two sources |
| `KEEPORG_MASTER_KEY_FILE` | path to a file holding the master key (base64 or 32 raw bytes); never created, and if it is missing the server runs but cannot create or merge vaults | | `KEEPORG_MASTER_KEY_FILE` | path to a file holding the master key (base64 or 32 raw bytes); never created, and the server does not start without it |
| `KEEPORG_EXTERNAL_URL` | the public base URL (`external_url`); `-url` overrides it | | `KEEPORG_EXTERNAL_URL` | the public base URL (`external_url`); `-url` overrides it |
| `KEEPORG_DATABASE_URL` | `postgres://…` or `sqlite:<path>` (`database`); default a SQLite file in the data directory; `-database` overrides it |
| `KEEPORG_TRUSTED_PROXIES` | reverse proxies whose `X-Forwarded-For` is believed, for per-client rate limits (`trusted_proxies`) |
| `KEEPORG_ADMIN_PASSWORD` | the first `admin` password, read only on an empty server and by `-reset-admin` | | `KEEPORG_ADMIN_PASSWORD` | the first `admin` password, read only on an empty server and by `-reset-admin` |
Everything else is configured in `server.json` or by flag. The details, Everything else is configured in `server.json` or by flag. The details,
@@ -132,7 +132,7 @@ class Api(baseUrl: String, var token: String?) {
val status = c.responseCode val status = c.responseCode
if (status == 204) return Pair(ByteArray(0), 0L) if (status == 204) return Pair(ByteArray(0), 0L)
if (status != 200) throw errorOf(c, status) 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 val version = c.getHeaderField("ETag")?.trim('"')?.toLongOrNull() ?: 0L
return Pair(data, version) return Pair(data, version)
} finally { } finally {
@@ -150,7 +150,7 @@ class Api(baseUrl: String, var token: String?) {
c.outputStream.use { it.write(data) } c.outputStream.use { it.write(data) }
val status = c.responseCode val status = c.responseCode
if (status != 200) throw errorOf(c, status) 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( return PushResult(
o.getLong("version"), o.getLong("version"),
o.optString("sha256"), o.optString("sha256"),
@@ -169,10 +169,12 @@ class Api(baseUrl: String, var token: String?) {
* stream costs a delay, never data. * stream costs a delay, never data.
*/ */
fun events(onEvent: (JSONObject) -> Unit) { fun events(onEvent: (JSONObject) -> Unit) {
val url = URL("$base/api/v1/events?access_token=" + enc(token ?: "")) // The token goes in the header, as everywhere else: in the URL it
val c = url.openConnection() as HttpURLConnection // would land in proxy logs.
val c = URL("$base/api/v1/events").openConnection() as HttpURLConnection
c.requestMethod = "GET" c.requestMethod = "GET"
c.setRequestProperty("Accept", "text/event-stream") c.setRequestProperty("Accept", "text/event-stream")
token?.takeIf { it.isNotEmpty() }?.let { c.setRequestProperty("Authorization", "Bearer $it") }
c.connectTimeout = 15_000 c.connectTimeout = 15_000
// The server sends a keepalive comment every 25 seconds. // The server sends a keepalive comment every 25 seconds.
c.readTimeout = 70_000 c.readTimeout = 70_000
@@ -182,6 +184,11 @@ class Api(baseUrl: String, var token: String?) {
var data = StringBuilder() var data = StringBuilder()
while (true) { while (true) {
val line = r.readLine() ?: return 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 { when {
line.isEmpty() -> { line.isEmpty() -> {
if (data.isNotEmpty()) { if (data.isNotEmpty()) {
@@ -231,7 +238,7 @@ class Api(baseUrl: String, var token: String?) {
val status = c.responseCode val status = c.responseCode
if (status < 200 || status > 299) throw errorOf(c, status) if (status < 200 || status > 299) throw errorOf(c, status)
if (status == 202) throw ApiException(202, "pending", "pending") 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) return if (text.isBlank()) JSONObject() else JSONObject(text)
} finally { } finally {
c.disconnect() c.disconnect()
@@ -240,7 +247,7 @@ class Api(baseUrl: String, var token: String?) {
private fun errorOf(c: HttpURLConnection, status: Int): ApiException { private fun errorOf(c: HttpURLConnection, status: Int): ApiException {
val text = try { val text = try {
(c.errorStream ?: c.inputStream)?.use { it.readAllBytesCompat() }?.decodeToString() ?: "" (c.errorStream ?: c.inputStream)?.use { it.readAllBytesCompat(MAX_JSON) }?.decodeToString() ?: ""
} catch (_: IOException) { } catch (_: IOException) {
"" ""
} }
@@ -257,13 +264,23 @@ class Api(baseUrl: String, var token: String?) {
private fun enc(s: String): String = URLEncoder.encode(s, "UTF-8") private fun enc(s: String): String = URLEncoder.encode(s, "UTF-8")
} }
/** readAllBytes() needs API 33; this works on every supported release. */ /** One database download, at most; the server's own limit is far below. */
internal fun InputStream.readAllBytesCompat(): ByteArray { 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 out = ByteArrayOutputStream()
val buf = ByteArray(16 * 1024) val buf = ByteArray(16 * 1024)
while (true) { while (true) {
val n = read(buf) val n = read(buf)
if (n < 0) break 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) out.write(buf, 0, n)
} }
return out.toByteArray() return out.toByteArray()
@@ -97,9 +97,13 @@ class KeepOrgProvider : DocumentsProvider() {
val file = fileOf(documentId) val file = fileOf(documentId)
val access = ParcelFileDescriptor.parseMode(mode) val access = ParcelFileDescriptor.parseMode(mode)
if (!mode.contains("w")) return ParcelFileDescriptor.open(file, access) if (!mode.contains("w")) return ParcelFileDescriptor.open(file, access)
// The application is about to save: reconcile once it has closed the // The application is about to save. Until it closes the file the
// file, which is the moment the new bytes are complete on disk. // 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) { return ParcelFileDescriptor.open(file, access, closeHandler) {
writers.computeIfPresent(file.name) { _, n -> if (n > 1) n - 1 else null }
Syncer.request(ctx(), "saved $documentId") Syncer.request(ctx(), "saved $documentId")
} }
} }
@@ -154,10 +158,14 @@ class KeepOrgProvider : DocumentsProvider() {
} }
private fun fileOf(documentId: String): File { private fun fileOf(documentId: String): File {
if (documentId.contains('/') || documentId.contains("..")) { if (documentId.contains('/') || documentId.contains('\u0000')) {
throw FileNotFoundException("bad document id") 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) if (!f.isFile) throw FileNotFoundException(documentId)
return f return f
} }
@@ -166,6 +174,11 @@ class KeepOrgProvider : DocumentsProvider() {
name.contains(".conflict-") || name.contains(".server-v") name.contains(".conflict-") || name.contains(".server-v")
companion object { 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 AUTHORITY = "cc.tiredsysadmin.android.keeporg.documents"
const val ROOT_ID = "keeporg" const val ROOT_ID = "keeporg"
const val ROOT_DOC = "root" const val ROOT_DOC = "root"
@@ -123,6 +123,12 @@ class LoginActivity : Activity() {
System.currentTimeMillis() + start.expiresIn * 1000L, System.currentTimeMillis() + start.expiresIn * 1000L,
) )
collectPendingSignIn(now = false) 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 { runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(start.verificationUrl))) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(start.verificationUrl)))
} }
@@ -215,6 +221,15 @@ class LoginActivity : Activity() {
} }
companion object { 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. */ /** Where the approval page links back to; see the manifest. */
const val RETURN_URL = "keeporg://device-approved" 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() val seen = dbs.map { it.id }.toSet()
for (gone in prefs.trackedIds - seen) { // A database no longer assigned stops being synchronized. Its file
// The file itself stays: deleting someone's password database is // stays — deleting someone's password database is not this app's
// not this app's decision. // decision — and its name stays retired.
prefs.forgetState(gone) prefs.retiredIds = (prefs.retiredIds + (prefs.trackedIds - seen)) - seen
}
prefs.trackedIds = seen prefs.trackedIds = seen
notifyPicker() notifyPicker()
return if (firstError == null) Result.success(Unit) else Result.failure(firstError) 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. */ /** Decides and applies the one action this database needs now. */
private fun reconcile(db: Db) { private fun reconcile(db: Db) {
val st = trackedState(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 file = File(dir, st.file)
val localExists = file.isFile val localExists = file.isFile
val local = if (localExists) file.readBytes() else ByteArray(0) 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) { when (action) {
Action.NOTHING -> if (prefs.status(db.id).isEmpty()) prefs.putStatus(db.id, "in sync") 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) 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 -> { Action.SERVER_WINS, Action.RESTORE -> {
val kept = keepConflictCopy(file, local) val kept = keepConflictCopy(file, local)
pull(db, st, file) pull(db, st, file, localSha)
val why = if (action == Action.RESTORE) val why = if (action == Action.RESTORE)
"read-only access: your save was kept as ${kept.name}" "read-only access: your save was kept as ${kept.name}"
else else
@@ -104,9 +110,19 @@ class Mirror(private val context: Context, private val api: Api, private val pre
prefs.putState(db.id, st) 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) val (data, version) = api.pull(db.id)
if (version == 0L || data.isEmpty()) return 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) writeAtomic(file, data)
st.version = version st.version = version
st.sha256 = digest(data) 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 st.sha256 = res.sha256
if (res.merged) { if (res.merged) {
// The result is neither copy: take what the server holds. // 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}") prefs.putStatus(db.id, "merged: ${res.mergeSummary}")
return return
} }
if (res.sha256 != digest(data)) { if (res.sha256 != digest(data)) {
pull(db, st, file) pull(db, st, file, digest(data))
return return
} }
prefs.putStatus(db.id, if (res.unchanged) "in sync" else "pushed version ${res.version}") 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 return
} }
val kept = keepConflictCopy(file, data) 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}") prefs.putStatus(db.id, "the server copy was newer: your save was kept as ${kept.name}")
} }
e.status == 403 -> { e.status == 403 -> {
val kept = keepConflictCopy(file, data) 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}") prefs.putStatus(db.id, "read-only access: your save was kept as ${kept.name}")
} }
else -> throw e 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. */ /** Returns the state for a database, following a rename made on the server. */
private fun trackedState(db: Db): Prefs.FileState { private fun trackedState(db: Db): Prefs.FileState {
val st = prefs.state(db.id) 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()) { if (st.file.isEmpty()) {
st.name = db.name st.name = db.name
st.file = fileName(db) 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 want = fileName(db)
val old = File(dir, st.file) val old = File(dir, st.file)
if (old.isFile && want != 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))) { if (!old.renameTo(File(dir, want))) {
Log.w(TAG, "could not rename ${st.file} to $want") Log.w(TAG, "could not rename ${st.file} to $want")
return st return st
@@ -174,10 +199,12 @@ class Mirror(private val context: Context, private val api: Api, private val pre
return st return st
} }
/** Names compare without regard to case, as in the desktop client. */
private fun fileName(db: Db): String { private fun fileName(db: Db): String {
val base = sanitize(db.name).ifEmpty { "database" } val base = sanitize(db.name).ifEmpty { "database" }
val name = "$base.kdbx" 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 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 { /**
* 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()) { for (ch in name.trim()) {
append(if (ch.isLetterOrDigit() || ch in "-_. ") ch else '-') 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() get() = sp.getStringSet("tracked", emptySet()) ?: emptySet()
set(v) = sp.edit().putStringSet("tracked", v).apply() 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. */ /** 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 putStatus(dbId: String, text: String) = sp.edit().putString("status_$dbId", text).apply()
@@ -57,7 +57,15 @@ Builds:
- yes - yes
output: android/app/build/outputs/apk/release/app-release-unsigned.apk output: android/app/build/outputs/apk/release/app-release-unsigned.apk
- versionName: 0.4.0
versionCode: 4
commit: v0.4.0-android
subdir: android/app
gradle:
- yes
output: android/app/build/outputs/apk/release/app-release-unsigned.apk
AutoUpdateMode: None AutoUpdateMode: None
UpdateCheckMode: Tags UpdateCheckMode: Tags
CurrentVersion: 0.3.0 CurrentVersion: 0.4.0
CurrentVersionCode: 3 CurrentVersionCode: 4
+3
View File
@@ -259,6 +259,9 @@ func (a *agent) signIn(ctx context.Context, open func(string)) error {
if err != nil { if err != nil {
return err return err
} }
if err := client.CheckVerificationURL(cfg.ServerURL, start.VerificationURL); err != nil {
return err
}
a.setPending(start.UserCode) a.setPending(start.UserCode)
defer a.setPending("") defer a.setPending("")
open(start.VerificationURL) open(start.VerificationURL)
+8
View File
@@ -159,3 +159,11 @@ func TestMirrorSettingsChanged(t *testing.T) {
t.Error("a new mirror directory did not restart the mirror") t.Error("a new mirror directory did not restart the mirror")
} }
} }
func TestTermSafeStripsControlCharacters(t *testing.T) {
var b strings.Builder
termSafe{&b}.Write([]byte("Team\x1b]0;pwned\x07 Vault\t1\n\x9b2J"))
if got := b.String(); got != "Team]0;pwned Vault\t1\n2J" {
t.Fatalf("got %q", got)
}
}
+11 -1
View File
@@ -3,6 +3,8 @@
package main package main
import ( import (
"bytes"
"encoding/xml"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -50,9 +52,17 @@ func setStartAtLogin(enable bool) error {
<string>Interactive</string> <string>Interactive</string>
</dict> </dict>
</plist> </plist>
`, AppID, exe) `, xmlEscape(AppID), xmlEscape(exe))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err return err
} }
return os.WriteFile(path, []byte(plist), 0o644) return os.WriteFile(path, []byte(plist), 0o644)
} }
// xmlEscape makes a string safe inside a plist element; a path with "&" or
// "<" would otherwise produce a LaunchAgent that never loads.
func xmlEscape(s string) string {
var b bytes.Buffer
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
+27 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
// autostartPath is the XDG autostart entry every freedesktop session reads. // autostartPath is the XDG autostart entry every freedesktop session reads.
@@ -39,13 +40,37 @@ func setStartAtLogin(enable bool) error {
Type=Application Type=Application
Name=KeepOrg Name=KeepOrg
Comment=KeePass database synchronization Comment=KeePass database synchronization
Exec="%s" tray Exec=%s tray
Icon=keeporg Icon=keeporg
Terminal=false Terminal=false
X-GNOME-Autostart-enabled=true X-GNOME-Autostart-enabled=true
`, exe) `, desktopExecArg(exe))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err return err
} }
return os.WriteFile(path, []byte(entry), 0o644) return os.WriteFile(path, []byte(entry), 0o644)
} }
// desktopExecArg quotes a path for a desktop entry's Exec key: inside double
// quotes, ", `, $ and \ are escaped with a backslash, % is doubled as a field
// code would be, and then the whole value is escaped again as a string, which
// doubles every backslash.
func desktopExecArg(path string) string {
var b strings.Builder
b.WriteByte('"')
for _, r := range path {
switch r {
case '"', '`', '$':
b.WriteString(`\\`) // \ escaped once more as a string
b.WriteRune(r)
case '\\':
b.WriteString(`\\\\`)
case '%':
b.WriteString("%%")
default:
b.WriteRune(r)
}
}
b.WriteByte('"')
return b.String()
}
+13
View File
@@ -0,0 +1,13 @@
//go:build linux || freebsd || openbsd || netbsd || dragonfly
package main
import "testing"
func TestDesktopExecArgEscapes(t *testing.T) {
got := desktopExecArg(`/opt/a "b" $c\d 100%`)
want := `"/opt/a \\"b\\" \\$c\\\\d 100%%"`
if got != want {
t.Fatalf("got %s, want %s", got, want)
}
}
+21 -6
View File
@@ -122,6 +122,9 @@ func browserLogin(ctx context.Context, api *client.API, label string) (*client.S
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := client.CheckVerificationURL(api.BaseURL, start.VerificationURL); err != nil {
return nil, err
}
fmt.Printf("Open this page and approve the code %s:\n\n %s\n\n", fmt.Printf("Open this page and approve the code %s:\n\n %s\n\n",
start.UserCode, start.VerificationURL) start.UserCode, start.VerificationURL)
openBrowser(start.VerificationURL) openBrowser(start.VerificationURL)
@@ -240,7 +243,7 @@ func cmdStatus(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return err return err
} }
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) w := tabwriter.NewWriter(stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "DATABASE\tACCESS\tSTRATEGY\tVERSION\tUPDATED BY\tUPDATED") fmt.Fprintln(w, "DATABASE\tACCESS\tSTRATEGY\tVERSION\tUPDATED BY\tUPDATED")
for _, d := range dbs { for _, d := range dbs {
when := "never" when := "never"
@@ -253,7 +256,7 @@ func cmdStatus(ctx context.Context, args []string) error {
} }
func printStatus(st []client.Status) { func printStatus(st []client.Status) {
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) w := tabwriter.NewWriter(stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "DATABASE\tFILE\tVERSION\tSTATE\tNOTE") fmt.Fprintln(w, "DATABASE\tFILE\tVERSION\tSTATE\tNOTE")
for _, s := range st { for _, s := range st {
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", s.Name, s.File, s.Version, s.State, s.Message) fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", s.Name, s.File, s.Version, s.State, s.Message)
@@ -302,7 +305,19 @@ func cmdTOTP(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return err return err
} }
sec, uri, err := api.EnrollTOTP(ctx) password, err := readPassword("Account password: ")
if err != nil {
return err
}
sec, uri, err := api.EnrollTOTP(ctx, password, "")
if client.Code(err) == "reauth_required" {
// A factor is on already; replacing it needs its current code.
code, cerr := prompt("Current one-time code: ")
if cerr != nil {
return cerr
}
sec, uri, err = api.EnrollTOTP(ctx, password, code)
}
if err != nil { if err != nil {
return err return err
} }
@@ -353,7 +368,7 @@ func cmdGroups(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return err return err
} }
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) w := tabwriter.NewWriter(stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tPARENT\tMEMBERS\tYOU ADMINISTER") fmt.Fprintln(w, "ID\tNAME\tPARENT\tMEMBERS\tYOU ADMINISTER")
for _, g := range groups { for _, g := range groups {
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%v\n", g.ID, g.Name, g.ParentID, g.Members, g.Admin) fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%v\n", g.ID, g.Name, g.ParentID, g.Members, g.Admin)
@@ -592,7 +607,7 @@ func cmdInvite(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return err return err
} }
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) w := tabwriter.NewWriter(stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "URL\tGROUP\tROLE\tUSES\tEXPIRES") fmt.Fprintln(w, "URL\tGROUP\tROLE\tUSES\tEXPIRES")
for _, i := range invites { for _, i := range invites {
fmt.Fprintf(w, "%s\t%s\t%s\t%d/%d\t%s\n", i.URL, i.GroupID, i.Role, i.Uses, i.MaxUses, fmt.Fprintf(w, "%s\t%s\t%s\t%d/%d\t%s\n", i.URL, i.GroupID, i.Role, i.Uses, i.MaxUses,
@@ -621,7 +636,7 @@ func cmdUsers(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return err return err
} }
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) w := tabwriter.NewWriter(stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "ID\tUSERNAME\tEMAIL\tADMIN\t2FA") fmt.Fprintln(w, "ID\tUSERNAME\tEMAIL\tADMIN\t2FA")
for _, u := range users { for _, u := range users {
fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%v\n", u.ID, u.Username, u.Email, u.ServerAdmin, u.TOTPEnabled) fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%v\n", u.ID, u.Username, u.Email, u.ServerAdmin, u.TOTPEnabled)
+35 -1
View File
@@ -2,8 +2,13 @@ package main
import ( import (
"fmt" "fmt"
"html"
"io"
"os"
"os/exec" "os/exec"
"runtime" "runtime"
"strings"
"unicode/utf8"
) )
// homepage is where About points. // homepage is where About points.
@@ -47,7 +52,10 @@ func notify(title, message string) {
fmt.Sprintf("display notification %q with title %q", message, title)).Start() fmt.Sprintf("display notification %q with title %q", message, title)).Start()
case "windows": case "windows":
default: default:
_ = exec.Command("notify-send", "--app-name=KeepOrg", "--icon=keeporg", title, message).Start() // "--" so a database named "-u critical" is a title, not an option;
// the body is markup to notification daemons, so it is escaped.
_ = exec.Command("notify-send", "--app-name=KeepOrg", "--icon=keeporg", "--",
html.EscapeString(title), html.EscapeString(message)).Start()
} }
} }
@@ -56,3 +64,29 @@ func aboutText(configPath string) string {
return fmt.Sprintf("KeepOrg %s\n\nKeePass database synchronization.\n\nConfiguration: %s\n%s", return fmt.Sprintf("KeepOrg %s\n\nKeePass database synchronization.\n\nConfiguration: %s\n%s",
Version, configPath, homepage) Version, configPath, homepage)
} }
// termSafe passes text through to a terminal without its control
// characters. Names, labels and URLs printed by the CLI come from the server,
// and escape sequences in them could rewrite what the terminal shows.
type termSafe struct{ w io.Writer }
func (t termSafe) Write(p []byte) (int, error) {
clean := strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' {
return r
}
// A lone byte 0x9b is a C1 control to some terminals and invalid
// UTF-8 to Go, which reads it as RuneError.
if r < 0x20 || (r >= 0x7f && r < 0xa0) || r == utf8.RuneError {
return -1
}
return r
}, string(p))
if _, err := io.WriteString(t.w, clean); err != nil {
return 0, err
}
return len(p), nil
}
// stdout is where commands print: standard output, made safe for a terminal.
var stdout io.Writer = termSafe{os.Stdout}
+42 -13
View File
@@ -27,8 +27,17 @@ import (
"cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/store"
) )
// externalURLEnv sets external_url, between the configuration file and -url. // Settings a deployment usually knows only at deploy time can come from the
const externalURLEnv = "KEEPORG_EXTERNAL_URL" // environment as well. Each sits between the configuration file and its flag.
const (
// externalURLEnv sets external_url, overridden by -url.
externalURLEnv = "KEEPORG_EXTERNAL_URL"
// databaseEnv sets database, overridden by -database. A PostgreSQL URL
// carries a password, which is why it belongs in a secret, not a file.
databaseEnv = "KEEPORG_DATABASE_URL"
// proxiesEnv sets trusted_proxies, comma-separated.
proxiesEnv = "KEEPORG_TRUSTED_PROXIES"
)
// Version is stamped at build time from versions.mk. // Version is stamped at build time from versions.mk.
var Version = "dev" var Version = "dev"
@@ -43,6 +52,8 @@ type fileConfig struct {
MaxDBSizeMB int64 `json:"max_db_size_mb"` MaxDBSizeMB int64 `json:"max_db_size_mb"`
TLSCert string `json:"tls_cert"` TLSCert string `json:"tls_cert"`
TLSKey string `json:"tls_key"` TLSKey string `json:"tls_key"`
Database string `json:"database"`
TrustedProxies []string `json:"trusted_proxies"`
MasterKeyFile string `json:"master_key_file"` MasterKeyFile string `json:"master_key_file"`
VaultKDF vaultKDF `json:"vault_kdf"` VaultKDF vaultKDF `json:"vault_kdf"`
OIDC server.OIDCConfig `json:"oidc"` OIDC server.OIDCConfig `json:"oidc"`
@@ -60,7 +71,7 @@ func (f fileConfig) toConfig() server.Config {
c := server.Config{ c := server.Config{
Addr: f.Addr, DataDir: f.DataDir, ExternalURL: f.ExternalURL, Addr: f.Addr, DataDir: f.DataDir, ExternalURL: f.ExternalURL,
TLSCert: f.TLSCert, TLSKey: f.TLSKey, OIDC: f.OIDC, TLSCert: f.TLSCert, TLSKey: f.TLSKey, OIDC: f.OIDC,
MasterKeyFile: f.MasterKeyFile, Database: f.Database, MasterKeyFile: f.MasterKeyFile, TrustedProxies: f.TrustedProxies,
} }
if f.VaultKDF.MemoryMB > 0 { if f.VaultKDF.MemoryMB > 0 {
c.VaultKDF = kdbx.KDFParams{ c.VaultKDF = kdbx.KDFParams{
@@ -87,6 +98,8 @@ func main() {
addr = flag.String("addr", "", "listen address (overrides the config file)") addr = flag.String("addr", "", "listen address (overrides the config file)")
dataDir = flag.String("data", "", "data directory (overrides the config file)") dataDir = flag.String("data", "", "data directory (overrides the config file)")
extURL = flag.String("url", "", "externally reachable base URL (overrides $"+externalURLEnv+" and the config file)") extURL = flag.String("url", "", "externally reachable base URL (overrides $"+externalURLEnv+" and the config file)")
database = flag.String("database", "", "postgres://… or sqlite:<path>; default sqlite:<data>/keeporg.db "+
"(overrides $"+databaseEnv+" and the config file)")
masterKey = flag.String("master-key-file", "", masterKey = flag.String("master-key-file", "",
"file holding the master key that wraps the vault keys, created if "+ "file holding the master key that wraps the vault keys, created if "+
"absent; $"+secret.MasterKeyEnv+" and $"+secret.MasterKeyFileEnv+ "absent; $"+secret.MasterKeyEnv+" and $"+secret.MasterKeyFileEnv+
@@ -131,6 +144,15 @@ func main() {
if v := strings.TrimSpace(os.Getenv(externalURLEnv)); v != "" { if v := strings.TrimSpace(os.Getenv(externalURLEnv)); v != "" {
cfg.ExternalURL = v cfg.ExternalURL = v
} }
if v := strings.TrimSpace(os.Getenv(databaseEnv)); v != "" {
cfg.Database = v
}
if v := strings.TrimSpace(os.Getenv(proxiesEnv)); v != "" {
cfg.TrustedProxies = strings.Split(v, ",")
}
if *database != "" {
cfg.Database = *database
}
if *addr != "" { if *addr != "" {
cfg.Addr = *addr cfg.Addr = *addr
} }
@@ -164,6 +186,7 @@ func main() {
if err != nil { if err != nil {
lg.Fatalf("start: %v", err) lg.Fatalf("start: %v", err)
} }
defer srv.Close()
if err := bootstrap(srv, lg); err != nil { if err := bootstrap(srv, lg); err != nil {
lg.Fatalf("bootstrap: %v", err) lg.Fatalf("bootstrap: %v", err)
} }
@@ -181,8 +204,8 @@ func main() {
go func() { go func() {
eff := srv.Config() eff := srv.Config()
lg.Printf("version %s listening on %s (external %s, data %s)", lg.Printf("version %s listening on %s (external %s, database %s)",
Version, eff.Addr, eff.ExternalURL, eff.DataDir) Version, eff.Addr, eff.ExternalURL, srv.DatabaseWhere())
var err error var err error
if eff.TLSCert != "" && eff.TLSKey != "" { if eff.TLSCert != "" && eff.TLSKey != "" {
err = hs.ListenAndServeTLS(eff.TLSCert, eff.TLSKey) err = hs.ListenAndServeTLS(eff.TLSCert, eff.TLSKey)
@@ -247,23 +270,28 @@ func bootstrap(srv *server.Server, lg *log.Logger) error {
} }
// resetAdministrator gives the server's administrator a new password, for // resetAdministrator gives the server's administrator a new password, for
// when the one printed at first start was not kept. It writes the state // when the one printed at first start was not kept. It writes to the
// document directly, so the server must be stopped: a running one holds the // database like any server process would, so a running server picks the
// state in memory and would write over this on its next change. // change up within seconds and need not be stopped.
// //
// Any second factor on that account is cleared too. Someone who has lost // Any second factor on that account is cleared too. Someone who has lost
// the password has often lost the authenticator with it, and a recovery // the password has often lost the authenticator with it, and a recovery
// that leaves you locked out is not one. // that leaves you locked out is not one.
func resetAdministrator(cfg server.Config, lg *log.Logger) error { func resetAdministrator(cfg server.Config, lg *log.Logger) error {
cfg.Defaults() cfg.Defaults()
path := filepath.Join(cfg.DataDir, "state.json") kr, err := secret.LoadKeyring(cfg.MasterKeyFile)
if _, err := os.Stat(path); err != nil { if err != nil {
return fmt.Errorf("no server state at %s: %w", path, err) return fmt.Errorf("master key: %w", err)
} }
st, err := store.Open(path) st, db, err := server.OpenStore(cfg, kr)
if err != nil { if err != nil {
return err return err
} }
defer db.Close()
defer st.Close()
if st.Empty() {
return fmt.Errorf("the database %s holds no server yet; start keeporgd first", db.Where)
}
password := os.Getenv("KEEPORG_ADMIN_PASSWORD") password := os.Getenv("KEEPORG_ADMIN_PASSWORD")
generated := false generated := false
@@ -313,7 +341,6 @@ func resetAdministrator(cfg server.Config, lg *log.Logger) error {
lg.Printf("reset %q to the password from KEEPORG_ADMIN_PASSWORD. Any second "+ lg.Printf("reset %q to the password from KEEPORG_ADMIN_PASSWORD. Any second "+
"factor on that account was cleared and its sessions were revoked.", name) "factor on that account was cleared and its sessions were revoked.", name)
} }
lg.Print("if keeporgd was running while this ran, restart it now so it reloads the change")
return nil return nil
} }
@@ -356,6 +383,8 @@ func writeExampleConfig(path string) error {
ExternalURL: "https://keeporg.example.com", ExternalURL: "https://keeporg.example.com",
SessionTTLHours: 24 * 90, SessionTTLHours: 24 * 90,
MaxDBSizeMB: 32, MaxDBSizeMB: 32,
Database: "sqlite:/var/lib/keeporg/keeporg.db",
TrustedProxies: []string{"127.0.0.1", "::1"},
MasterKeyFile: "/etc/keeporg/master.key", MasterKeyFile: "/etc/keeporg/master.key",
VaultKDF: vaultKDF{MemoryMB: 32, Iterations: 2, Lanes: 2}, VaultKDF: vaultKDF{MemoryMB: 32, Iterations: 2, Lanes: 2},
OIDC: server.OIDCConfig{ OIDC: server.OIDCConfig{
+10 -1
View File
@@ -24,7 +24,16 @@ server {
ssl_certificate {{ .nginx.variables.blog_ssl_certificate }}; ssl_certificate {{ .nginx.variables.blog_ssl_certificate }};
ssl_certificate_key {{ .nginx.variables.blog_ssl_certificate_key }}; ssl_certificate_key {{ .nginx.variables.blog_ssl_certificate_key }};
client_max_body_size 8192M; # Modern TLS only, whatever the host's defaults are.
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# The server refuses databases over max_db_size_mb (32 by default); a
# little headroom over that, and no more, so oversized uploads stop here.
client_max_body_size 64M;
server_name keeporg.tiredsysadmin.cc; server_name keeporg.tiredsysadmin.cc;
+4
View File
@@ -5,6 +5,10 @@ environment:
value: "/etc/keeporg/config.txt" value: "/etc/keeporg/config.txt"
- name: "KEEPORG_EXTERNAL_URL" - name: "KEEPORG_EXTERNAL_URL"
value: "https://keeporg.tiredsysadmin.cc" value: "https://keeporg.tiredsysadmin.cc"
# nginx reaches the pod from the cluster's private network; its
# X-Forwarded-For is what the sign-in rate limits count by.
- name: "KEEPORG_TRUSTED_PROXIES"
value: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
image: image:
name: "repo.rcmd.space/keeporg" name: "repo.rcmd.space/keeporg"
version: "v0.1.0" version: "v0.1.0"
+280 -84
View File
@@ -28,8 +28,11 @@ cmd/keeporgd/ server entry point: flags, config file, bootstrap
cmd/keeporg/ desktop client: the tray application (agent.go is its cmd/keeporg/ desktop client: the tray application (agent.go is its
logic, tray.go its menu), login, run, sync, status, logic, tray.go its menu), login, run, sync, status,
administration, start-at-login per platform administration, start-at-login per platform
internal/store/ domain model, JSON metadata store, ACL resolution internal/store/ domain model, the in-memory state and its sealed rows,
internal/blob/ versioned KDBX payload storage on disk ACL resolution
internal/blob/ versioned KDBX payloads, sealed, in the database
internal/sqldb/ SQLite or PostgreSQL: connection, dialect, schema,
generation lock, key check; sqldbtest for tests
internal/kdbx/ KDBX 4 codec and the merge engine, with its own crypto internal/kdbx/ KDBX 4 codec and the merge engine, with its own crypto
internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web, internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web,
web administration, page templates web administration, page templates
@@ -52,21 +55,33 @@ Makefile the only entry point for building and publishing
### Server (`keeporgd`) ### Server (`keeporgd`)
One process, one data directory, no database engine: One process, one database, one master key:
- `state.json` holds users, groups, memberships, databases, grants, invites, - **The database** is SQLite (`sqlite:<path>`, by default
sessions and pending device logins. For a merge vault it also holds that `<data_dir>/keeporg.db`) or PostgreSQL (`postgres://…`), chosen by the
vault's password, encrypted under the server's master key. `database` setting, `KEEPORG_DATABASE_URL` or `-database`. It holds
- `blobs/<database-id>/<version>.kdbx` holds the payloads, keeping the last everything: users, groups, memberships, databases, grants, invites,
20 versions of each database. sessions, pending device logins, and the last 20 versions of every KDBX
- The master key itself lives **outside** the data directory, so a copy of payload.
the data directory alone yields ciphertext. - **Every row is sealed** with AES-256-GCM under a key derived from the
master key, and named by an HMAC rather than by anything readable. The
database, its backups and its administrators see three tables of opaque
rows; see [Encryption at rest](#encryption-at-rest).
- **The master key** lives outside the database and outside the data
directory, so neither a database dump nor a copy of the data directory
yields anything readable. The server does not start without it, and does
not start with a different one.
`internal/store.Store` is the only writer of `state.json`. Every mutation `internal/store.Store` keeps the metadata in memory as one `State` and is
runs through `Update(func(*State) error)`, which applies the change to a the only writer of it. Every mutation runs through `Update(func(*State)
private copy, writes it with `write` + `fsync` + `rename`, and only then error)`, which applies the change to a private copy, writes the rows that
publishes it. A failed mutation changes nothing, and a crash leaves either changed in one transaction, and only then publishes the copy. A failed
the old document or the new one. mutation changes nothing, and a crash leaves the last committed state.
`internal/sqldb` is the database underneath: connection, dialect, schema.
`internal/blob` keeps the payloads in the same database. Both engines run
through `database/sql` with pure-Go drivers (`modernc.org/sqlite`,
`jackc/pgx`), so the server still builds with `CGO_ENABLED=0`.
Reads go through `Read(func(*State))` under a read lock. Access decisions are 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 pure functions over `*State` in `internal/store/acl.go`, which is why they
@@ -318,6 +333,7 @@ Three different things are encrypted here, and they are worth keeping apart:
fails to open instead of unlocking the wrong database. It is never fails to open instead of unlocking the wrong database. It is never
returned by the API, never logged, and never leaves the process. returned by the API, never logged, and never leaves the process.
3. **Account passwords.** Not stored at all; only PBKDF2-SHA256 verifiers. 3. **Account passwords.** Not stored at all; only PBKDF2-SHA256 verifiers.
4. **Everything else the server keeps**, in the database: see below.
The master key is looked for in three places, in order: The master key is looked for in three places, in order:
@@ -330,28 +346,56 @@ The master key is looked for in three places, in order:
Only the configured path is ever created, which is what makes a plain Only the configured path is ever created, which is what makes a plain
package install work with no setup. The two environment variables name a key package install work with no setup. The two environment variables name a key
that something else owns, so a missing one is reported rather than replaced: that something else owns, so a missing one is reported rather than replaced:
minting a key in its place would open none of the existing vaults. Without minting a key in its place would open nothing that is already stored. The
any of the three the server still runs and still serves every key-less server refuses to start without a key, and refuses one that is not the key
strategy; only vault creation and merging are refused, with a line in the the database was written with (`sqldb.CheckKey` opens a sealed marker stored
log saying so. at the first start). Carrying on would write rows nobody could read beside
rows this key cannot.
### Encryption at rest
The database holds three tables, and none of them says anything readable:
| Table | Row | Name of the row | Content |
| --- | --- | --- | --- |
| `keeporg_records` | one user, group, database record, grant, membership, invite, session or device login | `HMAC(records key, kind ‖ natural key)` | the object as JSON, sealed |
| `keeporg_blobs` | one stored version of one KDBX file | `HMAC(blobs key, database id)` and the version number | the KDBX bytes, sealed |
| `keeporg_meta` | schema version, write generation, key check | plain names | numbers, and the sealed key-check marker |
The records key and the blobs key are derived from the master key with
HMAC-SHA256 under distinct names (`secret.Keyring.Derive`), so a ciphertext
made for one purpose does not open under another. Each row is sealed with
AES-256-GCM, its own name as the authenticated data: a row copied over
another, or a version copied over another version, fails to open instead of
being served. A row's name is stable for the same object, which is what
lets a write touch only the rows that changed, and says nothing to anyone
without the key — session and invite tokens, which are bearer credentials,
appear only inside sealed rows.
What the database reveals is the number of rows, their sizes, and how many
versions each payload has. Nothing is queryable by SQL: the server reads
every record at start and answers from memory, which is also why access
decisions stay pure functions over `*State`.
KDBX is already encrypted by KeePass; sealing it again means the database
holds no readable KeePass headers either (KDF parameters, cipher choice),
and one backup of the database is the whole server.
What this buys and what it does not: What this buys and what it does not:
- A stolen copy of `/var/lib/keeporg` is useless on its own. The vaults are - A database dump, a backup, a replica or a curious database administrator
ciphertext and the wrapped keys do not open without the master key. gets ciphertext. So does a stolen copy of the data directory.
- A stolen copy of the data directory **and** the master key opens every - The database **and** the master key together open everything, including
merge vault. Keep them on different media, or feed the key in through every merge vault. Keep them on different media, or feed the key in
`KEEPORG_MASTER_KEY` from a secret manager or a systemd credential so it through `KEEPORG_MASTER_KEY_FILE` from a secret manager, a Kubernetes
never touches the disk at all. secret or a systemd credential so it never sits beside the data.
- Backups must cover both, separately. A backup of the data directory with no - Backups must cover both, separately, and a database backup without its
master key cannot be used to merge after a restore; the vaults still open key restores nothing. Losing the key loses the server.
for their users, and the keys can be stored again with
`keeporg db -id <id> -store-password`.
## Authentication ## Authentication
Three ways in, one kind of credential out: an opaque bearer token stored in Three ways in, one kind of credential out: an opaque bearer token, stored
`state.json` with an expiry (90 days by default). sealed in the database with an expiry (90 days by default).
- **Password** (`POST /api/v1/auth/login`), with the built-in TOTP second - **Password** (`POST /api/v1/auth/login`), with the built-in TOTP second
factor when the account has enrolled one. The server answers factor when the account has enrolled one. The server answers
@@ -369,6 +413,36 @@ only. The server is a confidential client: it exchanges the authorization
code over TLS with the token endpoint and reads the identity from the 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. userinfo endpoint, so there is no local JWT verification to get wrong.
### Proving who someone is
Every password and one-time code the server checks — API login, the sign-in
form, the device page, a password change, a second-factor change, a vault
deletion — goes through `verify` in `internal/server/authn.go`:
- **The hash is checked outside the store's lock.** It is deliberately
slow; under the write lock it stalled every other request, so a few
guesses a second were a denial of service. `commit` re-checks inside the
write that the account and its hash did not change in between.
- **Failures are limited** per client address (30 per 15 minutes) and per
account (10), and a client or account over budget gets `429` with
`Retry-After` before any hash is computed. Unauthenticated device and
single sign-on starts are limited per address too. The address is the
peer's, or, for a peer in `trusted_proxies` (`KEEPORG_TRUSTED_PROXIES`),
the right-most untrusted address in `X-Forwarded-For`; nobody else gets to
name their own address. Behind nginx, set it, or every client shares the
proxy's budget.
- **Unknown, disabled and password-less accounts take as long to refuse as
a wrong password** (a dummy hash is verified), so timing does not list the
usernames.
- **A one-time code is good once.** The step of the last accepted code is
stored on the account (`TOTPLastStep`) and anything at or before it is
refused, including the code that confirmed enrolment.
- **Re-authentication does not say which factor was wrong.** For a change
made from a live session (password, second factor, deletion), a wrong
password and a wrong code give the same answer: a stolen session must not
become a password oracle. Starting a second factor needs the password too,
since on a stolen session it would lock the owner out.
### Setting up OIDC ### Setting up OIDC
At the identity provider, register a confidential client with one redirect At the identity provider, register a confidential client with one redirect
@@ -388,7 +462,8 @@ and restart:
"client_secret": "...", "client_secret": "...",
"scopes": "openid profile email", "scopes": "openid profile email",
"auto_create": false, "auto_create": false,
"default_group_id": "" "default_group_id": "",
"link_verified_email": false
} }
``` ```
@@ -404,20 +479,33 @@ Two entry points use it, and both end at the same callback:
starts a sign-in that ends in a session cookie. starts a sign-in that ends in a session cookie.
- **A client**, from the device flow. `keeporg login -browser=sso` prints a - **A client**, from the device flow. `keeporg login -browser=sso` prints a
short code; the device page at `/device?code=…` offers the same link, this short code; the device page at `/device?code=…` offers the same link, this
time as `/oidc/login?code=<user code>`, and approving it releases the time as `/oidc/login?code=<user code>`. After the provider, the person is
token the waiting client is polling for. shown which device is asking, as whom, and its code, and must press
**Approve**: only then is the token released to the waiting client.
Arriving at the callback proves that a browser with a provider session was
sent there, not that its owner meant to let a device in, and before 0.12 a
single link click was enough to hand an attacker's device a session.
Every round trip is bound to the browser that started it (a `keeporg_oidc`
cookie holding the state, checked at the callback; the approval's nonce in
the same cookie, checked at `/oidc/approve`) and uses PKCE, so a forwarded
callback URL or a code intercepted on the way back is useless.
How an identity becomes an account: How an identity becomes an account:
1. If some account is already linked to this issuer and `sub`, that one. 1. If some account is already linked to this issuer and `sub`, that one.
2. Otherwise an unlinked local account whose username equals 2. Otherwise, only when `link_verified_email` is set and the provider says
`preferred_username`, or whose email matches `email`, is linked to it. `email_verified`, an unlinked local account with the same email — never a
This is how someone invited by link later moves to single sign-on. server administrator's, never a disabled one. **Never by username**:
whoever could choose `preferred_username=admin` at the provider would
otherwise have become the server administrator.
3. Otherwise, with `auto_create` set, a new account is provisioned and put 3. Otherwise, with `auto_create` set, a new account is provisioned and put
in `default_group_id` when one is given. Without it, sign-in is refused in `default_group_id` when one is given. Without it, sign-in is refused.
with "no account is linked to this identity; redeem an invite link
first", which is the setting to keep if membership should stay by An existing account moves to single sign-on from its own **Account** page:
invitation. **Link single sign-on** runs the round trip with that session's CSRF token
and links whatever identity comes back to it. That replaces the automatic
username matching that used to do this.
An account created this way has no password, so it cannot sign in with one An account created this way has no password, so it cannot sign in with one
and cannot set one through the password form. and cannot set one through the password form.
@@ -489,7 +577,7 @@ the `/api/v1/auth/sessions` and `/api/v1/auth/totp` endpoints:
out, or all but the current one. Pages and responses refer to a session by out, or all but the current one. Pages and responses refer to a session by
`sessionID`, a digest of its token, so the list never carries a token. `sessionID`, a digest of its token, so the list never carries a token.
`LastSeenAt` is refreshed by `markSeen` at most every five minutes, because `LastSeenAt` is refreshed by `markSeen` at most every five minutes, because
each refresh rewrites the state document. each refresh is a database write.
- *Two-factor authentication* turns on with nothing more than the session, - *Two-factor authentication* turns on with nothing more than the session,
since it only adds a check. Replacing or removing an active factor asks since it only adds a check. Replacing or removing an active factor asks
for the password and a current code: before this, `totp/enroll` reset the for the password and a current code: before this, `totp/enroll` reset the
@@ -527,16 +615,63 @@ database, encrypts it with the password from the form, stores it as version
state update fails, the blob is removed again, so a half-created vault cannot state update fails, the blob is removed again, so a half-created vault cannot
be left behind. be left behind.
## Security hardening (0.12)
An audit of every part — server authentication, the data plane and its
cryptography, both clients, and the deployment — found the problems below,
and all of the critical, high and medium ones were fixed with a regression
test each. The authentication changes are described under
[Proving who someone is](#proving-who-someone-is) and
[Setting up OIDC](#setting-up-oidc).
| Area | Was | Now |
| --- | --- | --- |
| Single sign-on | one click on a link approved an attacker's device | an explicit Approve in the browser that started it; state cookie; PKCE |
| Single sign-on | an identity named `admin` claimed the admin account | no linking by username; verified email only when allowed; never an administrator |
| Passwords | no attempt limit; each guess held the global write lock | per-address and per-account limits; hashing outside the lock |
| TOTP | a code could be replayed for its 90-second window | each code is accepted once |
| Merge vaults | a writer could push a header asking for 2⁶² AES rounds or 2 GiB of Argon2 memory and stop the server, or a gzip bomb of nested XML that overflowed the stack | KDF, payload and XML bounds in `internal/kdbx/limits.go`; merge-vault pushes are checked (`kdbx.Inspect`) before they are stored; at most two merges at a time |
| Several nodes | a stale node's push could overwrite a version another node had committed | the version is written in the same transaction as its metadata, after the generation lock |
| Tokens | `?access_token=` worked on every endpoint, and the clients put it in URLs that reached logs | only on the event stream, for old clients; current clients send the header |
| Event streams | a revoked device kept receiving events | the session is re-checked at every keepalive; disabled users get no events |
| Web pages | no CSP, framing allowed, cacheable, login CSRF | CSP, `frame-ancestors 'none'`, `no-store`, HSTS behind https; the sign-in form needs a pre-session token |
| Enumeration | admin endpoints said 403 for someone else's database, 400 for none | 404 for both; group administrators no longer see other people's email, sign-in methods or 2FA |
| Desktop client | the server chose what the desktop opened at sign-in (any URL, `file:`, a network share) | only the server's own `/device` page |
| Desktop client | a database whose name differed in case, or matched a file left by a lost database, adopted and uploaded another vault | names compare case-insensitively; files of lost databases stay retired; renames never replace a file; reserved suffixes and Windows device names are avoided |
| Clients | a save made while a pull ran could be overwritten without a trace; Android could read half a save | the file is re-hashed before it is replaced; Android leaves files open for writing alone |
| Clients | `http://` servers accepted silently; unbounded reads from the server; markup and options in notifications; control characters printed to the terminal | `https` required except for loopback (`allow_insecure_http`); bounded reads; `--` and escaping; control characters stripped |
| Deployment | the `.deb` service could not create its master key under `ProtectSystem=strict`, so a fresh install did not start | the package creates the key; more systemd sandboxing |
| Deployment | nginx: host TLS defaults, no HSTS, 8 GiB uploads; CI ran as root on any branch | TLS 1.2+ and modern ciphers, HSTS, 64 MiB; CI on `main` only |
Left for later, deliberately:
- **Rollback of database rows.** Someone with write access to the database
itself can replay an older sealed row (a revoked session, a user before a
demotion). A sealed digest over every row, checked at load, would catch
it; until then, write access to the database is as sensitive as the
master key.
- **The master key also keys the subkey derivation and the vault-key
wrapping directly.** Not exploitable, but cleaner as separate derived keys;
changing it means re-wrapping every stored vault key.
- **A cap on event streams per user**, and pinning the container's base
image by digest.
- **The Kubernetes NodePort** (32970) reaches the server without nginx,
Vouch or TLS. Make sure only nginx can reach it.
- **KDBX magic is not required of pushes to key-less databases.** The server
treats their bytes as opaque by design.
## Decisions worth knowing ## Decisions worth knowing
**No third-party Go dependencies in the server, no cgo outside the macOS **No cgo, and as few dependencies as the job allows.** The server builds
tray.** The server builds with `CGO_ENABLED=0` from the standard library with `CGO_ENABLED=0`: SSE instead of a WebSocket library, `crypto/pbkdf2`
alone: SSE instead of a WebSocket library, `crypto/pbkdf2` (new in Go 1.24)
for account passwords, hand-written ChaCha20, BLAKE2b and Argon2 for the for account passwords, hand-written ChaCha20, BLAKE2b and Argon2 for the
KDBX format, 60 lines of HMAC for TOTP, `html/template` for the pages. There KDBX format, 60 lines of HMAC for TOTP, `html/template` for the pages. Its
is no dependency tree to audit in the program that holds the vault keys; only third-party code is the two database drivers, both pure Go:
`go list -deps ./cmd/keeporgd` shows only the standard library and this `modernc.org/sqlite`, SQLite translated to Go, and `jackc/pgx` for
module, and the container builds without fetching anything. PostgreSQL. The standard library has no SQL driver, and writing either
would be a project of its own. Keeping them pure Go keeps static binaries,
cross-compilation and the `scratch` container; a cgo driver such as
SQLCipher was rejected for exactly that.
The desktop client takes exactly one, `fyne.io/systray` (with The desktop client takes exactly one, `fyne.io/systray` (with
`godbus/dbus` and `golang.org/x/sys` under it), because a tray icon cannot `godbus/dbus` and `golang.org/x/sys` under it), because a tray icon cannot
@@ -550,13 +685,34 @@ darwin client with cgo when it is built on a Mac; built elsewhere it keeps
every command but `tray` (`tray_nocgo.go`). The client only moves every command but `tray` (`tray_nocgo.go`). The client only moves
ciphertext, so the trade is in the program that never sees a key. ciphertext, so the trade is in the program that never sees a key.
**A JSON document instead of SQLite.** The metadata is hundreds of records, **A database, held in memory, sealed row by row.** Until 0.11 the server
never thousands: users, groups, grants. One writer with atomic rename gives kept one JSON document and a directory of KDBX files. Enterprise use needs a
durability and consistency without cgo or a migration story, and an operator real database — managed backups, replication, a PostgreSQL the operations
can read and back up the whole server state with `cat`. The cost is a single team already runs — and data that is encrypted at rest under a key the
node and a full rewrite per mutation. If this ever needs several servers or database never sees. Three choices follow from that:
tens of thousands of users, replace `internal/store` — the rest of the code
only touches it through `Read`, `Update` and the ACL functions. - *The state stays in memory.* The metadata is hundreds of records, never
thousands, and every request makes access decisions over it. Loading it
once and answering from memory kept `Read`, `Update` and every ACL function
unchanged, so the rest of the server did not move. The database is
durable storage, not a query engine.
- *Encryption is in the application, not the engine.* Sealing each row with
the master key works the same on SQLite and PostgreSQL and protects dumps
and backups, which disk encryption and PostgreSQL's own options do not.
The price is that nothing is queryable in SQL — which, for a server whose
rows are bearer tokens and password verifiers, is the point.
- *Writes are diffs.* `Update` compares each row's plaintext digest with the
last one stored and writes only what changed, so a session touch is one
row, not the whole state.
Several processes may share one PostgreSQL database. A write locks the
`generation` row (`SELECT … FOR UPDATE`), and if another process has
committed since this one loaded, the state is reloaded and the mutation
applied again on top. A watcher reloads within two seconds of another
process's write, so a session revoked through one process is refused by all
of them. What is not shared yet is the event stream: a client connected to
one process hears about pushes made through that process only, and picks up
the rest at its next periodic rescan.
**A mirror directory instead of FUSE.** The requirement is that a KeePass **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 application sees a regular `.kdbx` file. A real file in a real directory is
@@ -598,7 +754,7 @@ keeporgd: created the first administrator: username "admin" password "13e4…"
``` ```
Read it with `journalctl -u keeporgd`, or `podman logs <container>`. It is Read it with `journalctl -u keeporgd`, or `podman logs <container>`. It is
never written anywhere else: the state file holds only a PBKDF2 verifier. never written anywhere else: the database holds only a PBKDF2 verifier.
Set `KEEPORG_ADMIN_PASSWORD` before the first start to choose it instead, in Set `KEEPORG_ADMIN_PASSWORD` before the first start to choose it instead, in
which case nothing is printed. The variable is read only when the server has which case nothing is printed. The variable is read only when the server has
no accounts at all, so leaving it set afterwards does nothing. no accounts at all, so leaving it set afterwards does nothing.
@@ -615,10 +771,12 @@ sudo -u keeporg keeporgd -data /var/lib/keeporg -reset-admin
``` ```
It prints a new one, clears any second factor on that account, and revokes It prints a new one, clears any second factor on that account, and revokes
its sessions. The server must be stopped: a running one holds the state in its sessions. It needs the same `database` and master key as the server
memory and would write over the change. Nothing else is touched, and no (pass `-config /etc/keeporg/server.json`, or the same environment), and the
vault is affected — account passwords and vault passwords are different server may keep running: it writes to the database like any server process,
things. and the running one picks the change up within seconds. Nothing else is
touched, and no vault is affected — account passwords and vault passwords
are different things.
`/etc/keeporg/server.json`: `/etc/keeporg/server.json`:
@@ -626,6 +784,7 @@ things.
{ {
"addr": ":8765", "addr": ":8765",
"data_dir": "/var/lib/keeporg", "data_dir": "/var/lib/keeporg",
"database": "sqlite:/var/lib/keeporg/keeporg.db",
"external_url": "https://keeporg.example.com", "external_url": "https://keeporg.example.com",
"session_ttl_hours": 2160, "session_ttl_hours": 2160,
"max_db_size_mb": 32, "max_db_size_mb": 32,
@@ -649,17 +808,19 @@ the server, or set `tls_cert` and `tls_key`.
### Environment variables ### Environment variables
The server reads four variables of its own: the public URL, and secrets or The server reads six variables of its own: where its database and its
first-run material. Everything else — address, data directory, OIDC, public address are, and secrets or first-run material. Everything else — address, data directory, OIDC,
limits — is set only in `server.json` or by flag, with flags winning; there limits — is set only in `server.json` or by flag, with flags winning; there
is no environment equivalent for those. An empty or whitespace-only value is no environment equivalent for those. An empty or whitespace-only value
counts as unset. counts as unset.
| Variable | Read by | Meaning | | Variable | Read by | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `KEEPORG_MASTER_KEY` | every start | The master key itself, base64 of 32 bytes. Takes precedence over everything below. Never written anywhere. A value that does not decode has the same effect as a missing key file. | | `KEEPORG_MASTER_KEY` | every start | The master key itself, base64 of 32 bytes. Takes precedence over everything below. Never written anywhere. A value that does not decode stops the server from starting. |
| `KEEPORG_MASTER_KEY_FILE` | every start, when `KEEPORG_MASTER_KEY` is unset | Path to a file holding the key, as base64 text or exactly 32 raw bytes. The file must exist and is never created, since a new key would open none of the existing vaults. If it is missing or malformed the server logs `no master key, so this server cannot create or merge vaults` and runs without one: key-less strategies keep working, vault creation and merging are refused. Takes precedence over `master_key_file` / `-master-key-file`. | | `KEEPORG_MASTER_KEY_FILE` | every start, when `KEEPORG_MASTER_KEY` is unset | Path to a file holding the key, as base64 text or exactly 32 raw bytes. The file must exist and is never created, since a new key would open nothing already stored. If it is missing or malformed the server refuses to start. Takes precedence over `master_key_file` / `-master-key-file`. |
| `KEEPORG_DATABASE_URL` | every start, and `-reset-admin` | Where the data lives, as `database`: `postgres://user:password@host/db?sslmode=…` or `sqlite:<path>`. Overrides the file; `-database` overrides it. A PostgreSQL URL carries a password, so this belongs in a secret. The password is masked in the log. |
| `KEEPORG_EXTERNAL_URL` | every start | The base URL a browser reaches the server at, as `external_url`. Overrides the file; `-url` overrides it. Invite links, the device-approval page every client sign-in uses, and the OIDC redirect URI are built from it, and without any of the three it is `http://localhost<addr>`, which is wrong behind any proxy. | | `KEEPORG_EXTERNAL_URL` | every start | The base URL a browser reaches the server at, as `external_url`. Overrides the file; `-url` overrides it. Invite links, the device-approval page every client sign-in uses, and the OIDC redirect URI are built from it, and without any of the three it is `http://localhost<addr>`, which is wrong behind any proxy. |
| `KEEPORG_TRUSTED_PROXIES` | every start | Comma-separated addresses or CIDR ranges of reverse proxies whose `X-Forwarded-For` is believed, as `trusted_proxies`. Sign-in rate limits count per client address; behind a proxy without this, every client shares the proxy's budget. |
| `KEEPORG_ADMIN_PASSWORD` | first start on an empty state, and `-reset-admin` | The password for the built-in `admin` account instead of a generated one, which is then not logged. Ignored once any account exists, so leaving it set is harmless but pointless; unset it after the first start. | | `KEEPORG_ADMIN_PASSWORD` | first start on an empty state, and `-reset-admin` | The password for the built-in `admin` account instead of a generated one, which is then not logged. Ignored once any account exists, so leaving it set is harmless but pointless; unset it after the first start. |
With neither master-key variable set, the key comes from `master_key_file` With neither master-key variable set, the key comes from `master_key_file`
@@ -688,11 +849,27 @@ nginx `server_name`; the image's command passes neither `-config` nor
`KEEPORG_ADMIN_PASSWORD` is not set there, so the first start logs a `KEEPORG_ADMIN_PASSWORD` is not set there, so the first start logs a
generated password once. generated password once.
Back up `/var/lib/keeporg` **and** `/etc/keeporg/master.key`, separately. Back up the database **and** `/etc/keeporg/master.key`, separately. The
The data directory is the whole server: `state.json` plus the blobs. The database is the whole server: with SQLite that is `keeporg.db` (copy it with
master key is what turns the wrapped vault keys in it back into vault `sqlite3 keeporg.db ".backup …"` while the server runs, or stop it and copy
passwords; without it a restored server can still serve, but can no longer the file with its `-wal`); with PostgreSQL, whatever backs that database up
merge. already. Every row in it is sealed under the master key, so a backup without
the key restores nothing.
### Moving from 0.10 or earlier
A server up to 0.10 kept `state.json` and `blobs/` in the data directory.
On the first start of 0.11 with an empty database, it imports both — every
account, session, grant and stored version — so clients and their tokens
carry on without signing in again. The old files are then renamed to
`state.json.imported-<time>` and `blobs.imported-<time>` and the log says
so. **Delete them once the server works**: `state.json` holds session
tokens in the clear. A database that already holds data is never imported
into again; the log warns if the old files are still lying around.
The import runs against whichever database is configured, so moving
straight to PostgreSQL is one start with `KEEPORG_DATABASE_URL` set and the
old data directory mounted.
### Setting up a team ### Setting up a team
@@ -724,7 +901,7 @@ keeporg db -id <db-id> -forget-password # the server can no longer read it
### Running it in a container ### Running it in a container
```sh ```sh
make docker # keeporgd:<version>, about 9 MB make docker # keeporgd:<version>, about 25 MB
make docker-save # dist/keeporgd-<version>-oci.tar make docker-save # dist/keeporgd-<version>-oci.tar
``` ```
@@ -735,10 +912,11 @@ uid 65532. There is no shell in it, so `HEALTHCHECK` runs the binary with
needs `--format docker` for the image to keep its healthcheck, which the needs `--format docker` for the image to keep its healthcheck, which the
Makefile passes. Makefile passes.
Two directories matter, and the whole point is that they are separate: Two things matter, and the whole point is that they are separate:
- `/var/lib/keeporg` — state and blobs. Mount a volume. - the database — `/var/lib/keeporg/keeporg.db` on a volume, or PostgreSQL
- `/etc/keeporg` — `server.json` and, by default, the master key. through `KEEPORG_DATABASE_URL`;
- the master key — `/etc/keeporg` by default, or a provisioned secret.
**Give the master key somewhere durable.** The best option is a provisioned **Give the master key somewhere durable.** The best option is a provisioned
secret plus `KEEPORG_MASTER_KEY_FILE` naming where it landed, which is the secret plus `KEEPORG_MASTER_KEY_FILE` naming where it landed, which is the
@@ -747,17 +925,14 @@ convention compose, swarm and Kubernetes all follow:
The key is then neither in the process environment nor on a disk the The key is then neither in the process environment nor on a disk the
container can write, and the file may hold base64 or 32 raw bytes. container can write, and the file may hold base64 or 32 raw bytes.
`KEEPORG_MASTER_KEY` and a volume on `/etc/keeporg` also work. A container `KEEPORG_MASTER_KEY` and a volume on `/etc/keeporg` also work. A container
started with none of those mints a key started with none of those mints a key into its own layer and loses it on
into its own layer and loses it on the next `run`. That is survivable but the next `run`, and the next start then refuses to run:
annoying: the vaults still open for their users, and only merging stops. The
server says so at startup —
``` ```
WARNING: 1 of 1 stored vault keys do not open with this master key. start: database …: this master key does not open this database; start with the key it was created with
``` ```
— and a merging push then answers `409 merge_failed` rather than corrupting Nothing is overwritten. Restore the original key and it starts.
anything. Restore the original key and it resumes.
`packaging/docker/compose.yaml` is a worked example with `read_only`, `packaging/docker/compose.yaml` is a worked example with `read_only`,
`cap_drop: ALL` and `no-new-privileges`; the server writes one directory and `cap_drop: ALL` and `no-new-privileges`; the server writes one directory and
@@ -807,13 +982,29 @@ Then sign and index on the repository host (`fdroid publish && fdroid update`).
```sh ```sh
make check # gofmt, go vet, go test ./... make check # gofmt, go vet, go test ./...
make test-race # the same suite under the race detector make test-race # the same suite under the race detector
# the same suite on PostgreSQL: each test gets a fresh database there
KEEPORG_TEST_POSTGRES='postgres://postgres:secret@127.0.0.1:5432/postgres?sslmode=disable' go test ./...
``` ```
Every test that touches storage goes through `sqldbtest.URL`, which hands
out a temporary SQLite file, or a new PostgreSQL database dropped afterwards
when `KEEPORG_TEST_POSTGRES` is set. The suite has passed on both.
What the suite actually covers: What the suite actually covers:
- `internal/totp` against the RFC 6238 vectors. - `internal/totp` against the RFC 6238 vectors.
- `internal/store` for grant inheritance, sibling isolation, admin - `internal/store` for grant inheritance, sibling isolation, admin
inheritance, cycle safety, update rollback and expiry. inheritance, cycle safety, update rollback and expiry; that no row id or
body carries a username, email or token in the clear; that a change
rewrites only its own row; that two stores on one database see each
other's writes and re-apply a stale one on top; and that another master
key is refused.
- `internal/blob` for versioning, pruning, sealing, a version's row copied
over another failing to open, and importing the old on-disk layout.
- `internal/server` for importing a data directory from 0.10 (accounts,
sessions, versions; the old files set aside; no second import), and for
refusing to start without the master key or with another one.
- `internal/server` for every push verdict as a table, and an end-to-end HTTP - `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 pass over login, stale push, forced push, idempotent push, read-only
grants, invite redemption and reuse, TOTP enrolment, device login and the grants, invite redemption and reuse, TOTP enrolment, device login and the
@@ -866,8 +1057,13 @@ what an arm64 Linux build host needs.
- **The DMG is per architecture.** A universal `.app` needs `lipo` over both - **The DMG is per architecture.** A universal `.app` needs `lipo` over both
cgo builds, which `midi-webhook`'s Makefile shows how to do. cgo builds, which `midi-webhook`'s Makefile shows how to do.
- **One server node.** The JSON store is single-writer by design. Horizontal - **Several server nodes share a database, but not their event streams.**
scale means replacing `internal/store`. Writes are serialized and every node sees every write within two seconds,
but a client hears another node's pushes only at its next rescan. A
PostgreSQL `LISTEN`/`NOTIFY` relay into `Hub` would close that.
- **The metadata lives in memory.** Fine for thousands of accounts; far
beyond that, the per-write clone and the full load at start would want
the store to become per-row.
- **Merging requires the server to hold the vault key.** That is inherent, not - **Merging requires the server to hold the vault key.** That is inherent, not
an implementation gap: the alternative is a client-side merge, which would an implementation gap: the alternative is a client-side merge, which would
mean the pushing client fetching the server copy, merging locally and mean the pushing client fetching the server copy, merging locally and
+14 -2
View File
@@ -21,7 +21,7 @@ https, and every form carries a per-session CSRF token.
| POST | `/api/v1/auth/logout` | – | revokes the calling token | | POST | `/api/v1/auth/logout` | – | revokes the calling token |
| GET | `/api/v1/auth/whoami` | – | the account and its group memberships | | GET | `/api/v1/auth/whoami` | – | the account and its group memberships |
| POST | `/api/v1/auth/password` | `{current_password, new_password, totp}` | changes the account password; the current one is required, and the second factor when enrolled. Every other session of that account is revoked | | POST | `/api/v1/auth/password` | `{current_password, new_password, totp}` | changes the account password; the current one is required, and the second factor when enrolled. Every other session of that account is revoked |
| POST | `/api/v1/auth/totp/enroll` | `{password, totp}`, only when a factor is already on | `{secret, uri}`; not active until confirmed. Replacing an active factor without the password and current code is `403 reauth_required` | | POST | `/api/v1/auth/totp/enroll` | `{password, totp}`; `totp` only when a factor is already on | `{secret, uri}`; not active until confirmed. Without the password, `403 reauth_required`; replacing an active factor needs its current code too |
| POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` | | POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` |
| DELETE | `/api/v1/auth/totp` | `{password, totp}` | `{totp_enabled: false}`; `403 reauth_required`, `403 bad_credentials`, `403 totp_invalid` | | DELETE | `/api/v1/auth/totp` | `{password, totp}` | `{totp_enabled: false}`; `403 reauth_required`, `403 bad_credentials`, `403 totp_invalid` |
| GET | `/api/v1/auth/sessions` | – | `{sessions: [{id, label, browser, current, created_at, last_seen_at, expires_at}]}`, the caller's own | | GET | `/api/v1/auth/sessions` | – | `{sessions: [{id, label, browser, current, created_at, last_seen_at, expires_at}]}`, the caller's own |
@@ -32,6 +32,18 @@ https, and every form carries a per-session CSRF token.
A device code is single use: the first successful poll consumes it. A device code is single use: the first successful poll consumes it.
Sign-in attempts are limited per client address and per account. Over the
limit, `/auth/login`, `/auth/device/start` and the re-authenticating
endpoints answer `429 throttled` with `Retry-After`. A one-time code is
accepted once; a second use is `totp_invalid`. Device labels are trimmed to
64 characters.
Endpoints about a database answer `404 not_found` both for one that does not
exist and for one the caller has no access to. A push to a merge vault whose
KDBX header is malformed or demands more key derivation work than the server
opens (`internal/kdbx/limits.go`) is `400 invalid_kdbx`. A merge that cannot
get a turn within 30 seconds is `503 busy`.
A session `id` is a one-way digest of its token, never the token itself, so A session `id` is a one-way digest of its token, never the token itself, so
a list of sessions cannot be used to sign in as any of them. Failed a list of sessions cannot be used to sign in as any of them. Failed
re-authentication answers `403`, not `401`, so a client that drops its re-authentication answers `403`, not `401`, so a client that drops its
@@ -54,7 +66,7 @@ retry without it.
| GET | `/api/v1/databases` | `{databases: [{id, name, perm, merge_strategy, version, sha256, size, updated_at, updated_by}]}` | | 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: "<version>"`, `X-KeepOrg-SHA256`; `204` when the database has no content yet; `?version=<n>` reads a stored older version | | GET | `/api/v1/databases/{id}/blob` | bytes; `ETag: "<version>"`, `X-KeepOrg-SHA256`; `204` when the database has no content yet; `?version=<n>` reads a stored older version |
| PUT | `/api/v1/databases/{id}/blob` | body is the KDBX file. `If-Match` is required | | 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 | | GET | `/api/v1/events` | SSE, with the `Authorization` header like everything else. `?access_token=` is still accepted here, and only here, for clients before 0.12; it puts the token in proxy logs. The stream ends once the session is revoked or the account disabled |
### PUT semantics ### PUT semantics
+20 -3
View File
@@ -1,10 +1,27 @@
module cc.tiredsysadmin.keeporg module cc.tiredsysadmin.keeporg
go 1.24 go 1.26.0
require ( require (
fyne.io/systray v1.12.2 fyne.io/systray v1.12.2
golang.org/x/sys v0.27.0 github.com/jackc/pgx/v5 v5.11.0
golang.org/x/sys v0.49.0
modernc.org/sqlite v1.61.0
) )
require github.com/godbus/dbus/v5 v5.2.2 // indirect require (
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/text v0.29.0 // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+74 -6
View File
@@ -1,10 +1,78 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+121 -57
View File
@@ -1,93 +1,118 @@
// Package blob stores the KDBX payloads. Each database keeps a numbered // Package blob stores the KDBX payloads. Each database keeps a numbered
// version history, which makes a rejected push recoverable and a bad save // version history, which makes a rejected push recoverable and a bad save
// reversible. // reversible.
//
// The payloads live in the server's database next to the metadata, sealed
// under a key derived from the master key and named by a keyed digest of
// the database's id. KDBX is already encrypted by KeePass; sealing it again
// means the database holds no readable KeePass headers either, and that one
// backup of the database is the whole server.
package blob package blob
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"database/sql"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/sqldb"
) )
// KeepVersions is how many historical versions of each database are retained. // KeepVersions is how many historical versions of each database are retained.
const KeepVersions = 20 const KeepVersions = 20
type Store struct{ dir string } // timeout bounds one blob operation. A payload is at most tens of megabytes.
const timeout = time.Minute
// Open prepares dir as a blob store. type Store struct {
func Open(dir string) (*Store, error) { db *sqldb.DB
if err := os.MkdirAll(dir, 0o700); err != nil { key *secret.Keyring
return nil, err
}
return &Store{dir: dir}, nil
} }
// Open returns the blob store in db, sealing with key.
func Open(db *sqldb.DB, key *secret.Keyring) *Store { return &Store{db: db, key: key} }
// SHA256 is the lowercase hex digest used everywhere as a content fingerprint. // SHA256 is the lowercase hex digest used everywhere as a content fingerprint.
func SHA256(data []byte) string { func SHA256(data []byte) string {
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
// Put writes one version of a database. Writing the same version twice with func (s *Store) ref(dbID string) string { return s.key.MAC("blob\x00" + dbID) }
// the same bytes is a no-op, so a retried request is harmless.
func (s *Store) Put(dbID string, version int64, data []byte) error { // aad binds a sealed payload to its database and version, so a row copied
dir := filepath.Join(s.dir, dbID) // over another one fails to open instead of serving the wrong file.
if err := os.MkdirAll(dir, 0o700); err != nil { func aad(ref string, version int64) []byte {
return err return []byte(ref + "/" + strconv.FormatInt(version, 10))
}
path := s.versionPath(dbID, version)
if old, err := os.ReadFile(path); err == nil && SHA256(old) == SHA256(data) {
return nil
}
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
} }
// Get reads one version of a database. // Put writes one version of a database. Writing a version again replaces it,
// so a retried request is harmless.
func (s *Store) Put(dbID string, version int64, data []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return s.put(ctx, s.db, dbID, version, data)
}
// PutTx is Put inside a transaction, for a version written together with
// the metadata that names it: a version a concurrent writer committed can
// then never be overwritten by a write whose metadata turns out stale.
func (s *Store) PutTx(ctx context.Context, tx *sql.Tx, dbID string, version int64, data []byte) error {
return s.put(ctx, tx, dbID, version, data)
}
func (s *Store) put(ctx context.Context, q sqldb.Querier, dbID string, version int64, data []byte) error {
ref := s.ref(dbID)
_, err := q.ExecContext(ctx, s.db.Q(`INSERT INTO keeporg_blobs (ref, version, body)
VALUES (?, ?, ?) ON CONFLICT (ref, version) DO UPDATE SET body = excluded.body`),
ref, version, s.key.Seal(aad(ref, version), data))
return err
}
// Get reads one version of a database. A version that is not stored is
// fs.ErrNotExist.
func (s *Store) Get(dbID string, version int64) ([]byte, error) { func (s *Store) Get(dbID string, version int64) ([]byte, error) {
return os.ReadFile(s.versionPath(dbID, version)) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
ref := s.ref(dbID)
var body []byte
err := s.db.QueryRowContext(ctx, s.db.Q(`SELECT body FROM keeporg_blobs
WHERE ref = ? AND version = ?`), ref, version).Scan(&body)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("blob %s v%d: %w", dbID, version, fs.ErrNotExist)
}
if err != nil {
return nil, err
}
return s.key.Open(aad(ref, version), body)
} }
// Versions lists the stored versions of a database, oldest first. // Versions lists the stored versions of a database, oldest first.
func (s *Store) Versions(dbID string) []int64 { func (s *Store) Versions(dbID string) []int64 {
entries, err := os.ReadDir(filepath.Join(s.dir, dbID)) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
rows, err := s.db.QueryContext(ctx, s.db.Q(`SELECT version FROM keeporg_blobs
WHERE ref = ? ORDER BY version`), s.ref(dbID))
if err != nil { if err != nil {
return nil return nil
} }
defer rows.Close()
out := []int64{} out := []int64{}
for _, e := range entries { for rows.Next() {
name := e.Name() var v int64
if !strings.HasSuffix(name, ".kdbx") { if rows.Scan(&v) == nil {
continue
}
if v, err := strconv.ParseInt(strings.TrimSuffix(name, ".kdbx"), 10, 64); err == nil {
out = append(out, v) out = append(out, v)
} }
} }
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out return out
} }
@@ -97,19 +122,58 @@ func (s *Store) Prune(dbID string) error {
if len(vs) <= KeepVersions { if len(vs) <= KeepVersions {
return nil return nil
} }
for _, v := range vs[:len(vs)-KeepVersions] { ctx, cancel := context.WithTimeout(context.Background(), timeout)
if err := os.Remove(s.versionPath(dbID, v)); err != nil { defer cancel()
_, err := s.db.ExecContext(ctx, s.db.Q(`DELETE FROM keeporg_blobs WHERE ref = ? AND version < ?`),
s.ref(dbID), vs[len(vs)-KeepVersions])
return err return err
} }
}
return nil
}
// Forget deletes every version of a database. // Forget deletes every version of a database.
func (s *Store) Forget(dbID string) error { func (s *Store) Forget(dbID string) error {
return os.RemoveAll(filepath.Join(s.dir, dbID)) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
_, err := s.db.ExecContext(ctx, s.db.Q(`DELETE FROM keeporg_blobs WHERE ref = ?`), s.ref(dbID))
return err
} }
func (s *Store) versionPath(dbID string, version int64) string { // ImportDir copies the blobs/<database id>/<version>.kdbx tree that servers
return filepath.Join(s.dir, dbID, fmt.Sprintf("%020d.kdbx", version)) // before 0.11 kept on disk into the store, and says how many it copied.
func (s *Store) ImportDir(dir string) (int, error) {
dbs, err := os.ReadDir(dir)
if errors.Is(err, fs.ErrNotExist) {
return 0, nil
}
if err != nil {
return 0, err
}
n := 0
for _, d := range dbs {
if !d.IsDir() {
continue
}
files, err := os.ReadDir(filepath.Join(dir, d.Name()))
if err != nil {
return n, err
}
for _, f := range files {
name := f.Name()
if !strings.HasSuffix(name, ".kdbx") {
continue
}
v, err := strconv.ParseInt(strings.TrimSuffix(name, ".kdbx"), 10, 64)
if err != nil {
continue
}
data, err := os.ReadFile(filepath.Join(dir, d.Name(), name))
if err != nil {
return n, err
}
if err := s.Put(d.Name(), v, data); err != nil {
return n, err
}
n++
}
}
return n, nil
} }
+66 -4
View File
@@ -1,12 +1,32 @@
package blob package blob
import "testing" import (
"bytes"
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"testing"
func TestPutGetIsIdempotentAndVersioned(t *testing.T) { "cc.tiredsysadmin.keeporg/internal/secret"
s, err := Open(t.TempDir()) "cc.tiredsysadmin.keeporg/internal/sqldb"
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
)
func open(t *testing.T) (*Store, *sqldb.DB) {
t.Helper()
db, err := sqldb.Open(context.Background(), sqldbtest.URL(t))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { db.Close() })
k, _ := secret.NewKeyring(bytes.Repeat([]byte{3}, 32))
return Open(db, k), db
}
func TestPutGetIsIdempotentAndVersioned(t *testing.T) {
s, _ := open(t)
if err := s.Put("db", 1, []byte("v1")); err != nil { if err := s.Put("db", 1, []byte("v1")); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -23,15 +43,39 @@ func TestPutGetIsIdempotentAndVersioned(t *testing.T) {
if vs := s.Versions("db"); len(vs) != 2 || vs[0] != 1 || vs[1] != 2 { if vs := s.Versions("db"); len(vs) != 2 || vs[0] != 1 || vs[1] != 2 {
t.Fatalf("versions = %v", vs) t.Fatalf("versions = %v", vs)
} }
if _, err := s.Get("db", 9); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("missing version: %v", err)
}
}
func TestPayloadsAreSealedAndBoundToTheirVersion(t *testing.T) {
s, db := open(t)
_ = s.Put("db", 1, []byte("KDBX-HEADER-one"))
_ = s.Put("db", 2, []byte("KDBX-HEADER-two"))
var body []byte
if err := db.QueryRow(`SELECT body FROM keeporg_blobs WHERE version = 1`).Scan(&body); err != nil {
t.Fatal(err)
}
if bytes.Contains(body, []byte("KDBX")) {
t.Fatal("a payload is stored in the clear")
}
// Moving version 1's row over version 2 must not serve it as version 2.
if _, err := db.Exec(db.Q(`UPDATE keeporg_blobs SET body = ? WHERE version = 2`), body); err != nil {
t.Fatal(err)
}
if _, err := s.Get("db", 2); err == nil {
t.Fatal("a payload opened under another version's name")
}
} }
func TestPruneKeepsNewest(t *testing.T) { func TestPruneKeepsNewest(t *testing.T) {
s, _ := Open(t.TempDir()) s, _ := open(t)
for i := int64(1); i <= KeepVersions+5; i++ { for i := int64(1); i <= KeepVersions+5; i++ {
if err := s.Put("db", i, []byte{byte(i)}); err != nil { if err := s.Put("db", i, []byte{byte(i)}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
_ = s.Put("other", 1, []byte("x"))
if err := s.Prune("db"); err != nil { if err := s.Prune("db"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -39,4 +83,22 @@ func TestPruneKeepsNewest(t *testing.T) {
if len(vs) != KeepVersions || vs[0] != 6 { if len(vs) != KeepVersions || vs[0] != 6 {
t.Fatalf("after prune = %v", vs) t.Fatalf("after prune = %v", vs)
} }
if err := s.Forget("db"); err != nil || len(s.Versions("db")) != 0 || len(s.Versions("other")) != 1 {
t.Fatalf("forget: %v, left %v / %v", err, s.Versions("db"), s.Versions("other"))
}
}
func TestImportDirReadsTheOldLayout(t *testing.T) {
s, _ := open(t)
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "abc"), 0o700)
os.WriteFile(filepath.Join(dir, "abc", "00000000000000000003.kdbx"), []byte("three"), 0o600)
os.WriteFile(filepath.Join(dir, "abc", "00000000000000000004.kdbx.tmp"), []byte("junk"), 0o600)
n, err := s.ImportDir(dir)
if err != nil || n != 1 {
t.Fatalf("import = %d, %v", n, err)
}
if got, err := s.Get("abc", 3); err != nil || string(got) != "three" {
t.Fatalf("imported = %q, %v", got, err)
}
} }
+5 -3
View File
@@ -153,13 +153,15 @@ func (a *API) ChangePassword(ctx context.Context, current, next, totp string) er
} }
// EnrollTOTP starts second-factor enrolment and returns the shared secret // EnrollTOTP starts second-factor enrolment and returns the shared secret
// and its otpauth URI. // and its otpauth URI. It needs the account password, and the current code
func (a *API) EnrollTOTP(ctx context.Context) (secret, uri string, err error) { // when a second factor is already on and is being replaced.
func (a *API) EnrollTOTP(ctx context.Context, password, code string) (secret, uri string, err error) {
var out struct { var out struct {
Secret string `json:"secret"` Secret string `json:"secret"`
URI string `json:"uri"` URI string `json:"uri"`
} }
err = a.do(ctx, http.MethodPost, "/api/v1/auth/totp/enroll", nil, &out) err = a.do(ctx, http.MethodPost, "/api/v1/auth/totp/enroll",
map[string]string{"password": password, "totp": code}, &out)
return out.Secret, out.URI, err return out.Secret, out.URI, err
} }
+14 -6
View File
@@ -16,6 +16,9 @@ import (
"time" "time"
) )
// maxPayload bounds one database download.
const maxPayload = 256 << 20
// API talks to one KeepOrg server as one user. // API talks to one KeepOrg server as one user.
type API struct { type API struct {
BaseURL string BaseURL string
@@ -185,10 +188,15 @@ func (a *API) Pull(ctx context.Context, dbID string) ([]byte, int64, error) {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, 0, decodeError(resp) return nil, 0, decodeError(resp)
} }
data, err := io.ReadAll(resp.Body) // The server's own limit is tens of megabytes; a server that sends more
// is not one to trust with memory.
data, err := io.ReadAll(io.LimitReader(resp.Body, maxPayload+1))
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
if len(data) > maxPayload {
return nil, 0, fmt.Errorf("the server sent more than %d MiB for one database", maxPayload>>20)
}
v, _ := strconv.ParseInt(strings.Trim(resp.Header.Get("ETag"), `"`), 10, 64) v, _ := strconv.ParseInt(strings.Trim(resp.Header.Get("ETag"), `"`), 10, 64)
return data, v, nil return data, v, nil
} }
@@ -216,7 +224,7 @@ func (a *API) Push(ctx context.Context, dbID string, baseVersion int64, data []b
return nil, decodeError(resp) return nil, decodeError(resp)
} }
var out PushResult var out PushResult
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
return nil, err return nil, err
} }
return &out, nil return &out, nil
@@ -236,10 +244,10 @@ type Event struct {
// The caller reconnects; losing events is safe because every notification // The caller reconnects; losing events is safe because every notification
// only ever triggers a full reconcile. // only ever triggers a full reconcile.
func (a *API) Events(ctx context.Context, onEvent func(Event)) error { func (a *API) Events(ctx context.Context, onEvent func(Event)) error {
// The token travels as a query parameter because an event stream has no // The token goes in the header like everywhere else. In the URL it
// place to put a header on reconnect in every client runtime. // would land in proxy logs, and in every error message Go makes about
u := a.BaseURL + "/api/v1/events?access_token=" + url.QueryEscape(a.Token) // a failed request, which this client logs.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req, err := a.request(ctx, http.MethodGet, "/api/v1/events", nil)
if err != nil { if err != nil {
return err return err
} }
+34
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -43,6 +44,10 @@ type Config struct {
LogFile string `json:"log_file"` LogFile string `json:"log_file"`
// Notifications turns the tray's desktop notifications on or off. // Notifications turns the tray's desktop notifications on or off.
Notifications bool `json:"notifications"` Notifications bool `json:"notifications"`
// AllowInsecureHTTP permits an http:// server_url that is not this
// machine. Without TLS the password, the session token and every vault
// cross the network readable and alterable by anyone on the path.
AllowInsecureHTTP bool `json:"allow_insecure_http"`
} }
// ErrNoConfig means the client has not been logged in yet. // ErrNoConfig means the client has not been logged in yet.
@@ -134,6 +139,10 @@ func (c *Config) Validate() error {
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("server_url %q must be an http:// or https:// URL", c.ServerURL) return fmt.Errorf("server_url %q must be an http:// or https:// URL", c.ServerURL)
} }
if u.Scheme == "http" && !isLoopback(u.Hostname()) && !c.AllowInsecureHTTP {
return fmt.Errorf("server_url %q is not encrypted; use https://, "+
"or set allow_insecure_http if you really mean it", c.ServerURL)
}
} }
if c.CAFile != "" { if c.CAFile != "" {
if _, err := os.Stat(c.CAFile); err != nil { if _, err := os.Stat(c.CAFile); err != nil {
@@ -143,6 +152,31 @@ func (c *Config) Validate() error {
return nil return nil
} }
func isLoopback(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// CheckVerificationURL makes sure a page the server asks the client to open
// in a browser is on that server. A compromised server must not be able to
// make the desktop open whatever it likes — a file: URL, a network share
// that runs a program, another application's URL scheme.
func CheckVerificationURL(serverURL, raw string) error {
srv, err := url.Parse(serverURL)
if err != nil {
return err
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != srv.Scheme || !strings.EqualFold(u.Host, srv.Host) ||
u.User != nil || u.Path != strings.TrimRight(srv.Path, "/")+"/device" {
return fmt.Errorf("the server asked to open %q, which is not its own approval page", raw)
}
return nil
}
// API returns a client for the configured server, signed in with the stored // API returns a client for the configured server, signed in with the stored
// token when there is one. // token when there is one.
func (c *Config) API() (*API, error) { func (c *Config) API() (*API, error) {
+42
View File
@@ -119,3 +119,45 @@ func TestLockDirAdmitsOneAgent(t *testing.T) {
} }
again() again()
} }
func TestCheckVerificationURLStaysOnTheServer(t *testing.T) {
ok := []string{"https://k.example.com/device?code=ABCD-EFGH"}
bad := []string{
"file:///Applications/Calculator.app",
`\\attacker\share\x.exe`,
"https://evil.example/device?code=X",
"http://k.example.com/device?code=X",
"https://user@k.example.com/device",
"https://k.example.com/somewhere-else",
"keeporg://device-approved",
}
for _, u := range ok {
if err := CheckVerificationURL("https://k.example.com", u); err != nil {
t.Errorf("%s refused: %v", u, err)
}
}
for _, u := range bad {
if err := CheckVerificationURL("https://k.example.com", u); err == nil {
t.Errorf("%s accepted", u)
}
}
}
func TestPlainHTTPIsRefusedExceptLocally(t *testing.T) {
c := DefaultConfig()
for url, wantErr := range map[string]bool{
"http://keeporg.example.com": true,
"http://127.0.0.1:8765": false,
"http://localhost:8765": false,
"https://keeporg.example.com": false,
} {
c.ServerURL = url
if err := c.Validate(); (err != nil) != wantErr {
t.Errorf("%s: %v", url, err)
}
}
c.ServerURL, c.AllowInsecureHTTP = "http://keeporg.example.com", true
if err := c.Validate(); err != nil {
t.Errorf("allow_insecure_http ignored: %v", err)
}
}
+86 -16
View File
@@ -30,6 +30,11 @@ type fileState struct {
Version int64 `json:"version"` Version int64 `json:"version"`
SHA256 string `json:"sha256"` SHA256 string `json:"sha256"`
ConflictMarker string `json:"conflict_marker,omitempty"` ConflictMarker string `json:"conflict_marker,omitempty"`
// Retired marks a database this device no longer has access to. Its
// file stays where it is, and its name stays taken: a database created
// later under the same name must not adopt — and upload — a file that
// belonged to another.
Retired bool `json:"retired,omitempty"`
} }
// Status is a human-readable account of one mirrored database. // Status is a human-readable account of one mirrored database.
@@ -192,12 +197,12 @@ func (m *Mirror) SyncAll(ctx context.Context) error {
} }
} }
} }
// A database that is no longer assigned stops being tracked. Its file is // A database that is no longer assigned stops being synchronized. Its
// left alone: deleting a user's local copy of a password database is not // file is left alone — deleting a user's local copy of a password
// this program's decision. // database is not this program's decision — and its name stays retired.
for id := range m.st { for id, st := range m.st {
if !seen[id] { if !seen[id] {
delete(m.st, id) st.Retired = true
delete(m.status, id) delete(m.status, id)
} }
} }
@@ -263,7 +268,7 @@ func (m *Mirror) reconcile(ctx context.Context, db Database) error {
return nil return nil
case actPull: case actPull:
return m.pull(ctx, db, st, path) return m.pull(ctx, db, st, path, localSHA)
case actPush, actPushForMerge: case actPush, actPushForMerge:
return m.push(ctx, db, st, path, local, false) return m.push(ctx, db, st, path, local, false)
@@ -276,7 +281,7 @@ func (m *Mirror) reconcile(ctx context.Context, db Database) error {
if err != nil { if err != nil {
return err return err
} }
if err := m.pull(ctx, db, st, path); err != nil { if err := m.pull(ctx, db, st, path, localSHA); err != nil {
return err return err
} }
why := "the server copy was newer, your save was kept as " + filepath.Base(copyPath) why := "the server copy was newer, your save was kept as " + filepath.Base(copyPath)
@@ -306,7 +311,15 @@ func (m *Mirror) reconcile(ctx context.Context, db Database) error {
return nil return nil
} }
func (m *Mirror) pull(ctx context.Context, db Database, st *fileState, path string) error { // errChangedDuringSync means the local file was saved while a pass was
// deciding about it. Nothing is overwritten; the next pass sees the save.
var errChangedDuringSync = errors.New("the file was saved during synchronization; trying again")
// pull replaces the local file with the server copy. expectSHA is the digest
// of the local file the decision was made on ("" for none): if the file is
// different now, a KeePass application saved it in the meantime, and
// replacing it would lose that save without a trace.
func (m *Mirror) pull(ctx context.Context, db Database, st *fileState, path, expectSHA string) error {
data, version, err := m.api.Pull(ctx, db.ID) data, version, err := m.api.Pull(ctx, db.ID)
if err != nil { if err != nil {
return err return err
@@ -314,6 +327,13 @@ func (m *Mirror) pull(ctx context.Context, db Database, st *fileState, path stri
if version == 0 || len(data) == 0 { if version == 0 || len(data) == 0 {
return nil // nothing stored yet return nil // nothing stored yet
} }
now, exists, err := readFileIfExists(path)
if err != nil {
return err
}
if (exists && digest(now) != expectSHA) || (!exists && expectSHA != "") {
return errChangedDuringSync
}
if err := writeFileAtomic(path, data); err != nil { if err := writeFileAtomic(path, data); err != nil {
return err return err
} }
@@ -332,7 +352,7 @@ func (m *Mirror) push(ctx context.Context, db Database, st *fileState, path stri
// The server folded our save into someone else's. What it holds // The server folded our save into someone else's. What it holds
// now is neither copy, so take it. // now is neither copy, so take it.
m.log.Printf("%s: server merged version %d (%s)", db.Name, res.Version, res.MergeSummary) m.log.Printf("%s: server merged version %d (%s)", db.Name, res.Version, res.MergeSummary)
if err := m.pull(ctx, db, st, path); err != nil { if err := m.pull(ctx, db, st, path, digest(data)); err != nil {
return err return err
} }
m.setStatus(db, "merged", res.MergeSummary) m.setStatus(db, "merged", res.MergeSummary)
@@ -341,7 +361,7 @@ func (m *Mirror) push(ctx context.Context, db Database, st *fileState, path stri
if res.SHA256 != digest(data) { if res.SHA256 != digest(data) {
// Whatever the server stored differs from what we sent, so the // Whatever the server stored differs from what we sent, so the
// local file is no longer what the vault holds. // local file is no longer what the vault holds.
return m.pull(ctx, db, st, path) return m.pull(ctx, db, st, path, digest(data))
} }
if !res.Unchanged { if !res.Unchanged {
m.log.Printf("%s: pushed version %d", db.Name, res.Version) m.log.Printf("%s: pushed version %d", db.Name, res.Version)
@@ -371,7 +391,7 @@ func (m *Mirror) push(ctx context.Context, db Database, st *fileState, path stri
if cerr != nil { if cerr != nil {
return cerr return cerr
} }
if perr := m.pull(ctx, db, st, path); perr != nil { if perr := m.pull(ctx, db, st, path, digest(data)); perr != nil {
return perr return perr
} }
why := "the server copy was newer, your save was kept as " + filepath.Base(copyPath) why := "the server copy was newer, your save was kept as " + filepath.Base(copyPath)
@@ -386,7 +406,7 @@ func (m *Mirror) push(ctx context.Context, db Database, st *fileState, path stri
if cerr != nil { if cerr != nil {
return cerr return cerr
} }
if perr := m.pull(ctx, db, st, path); perr != nil { if perr := m.pull(ctx, db, st, path, digest(data)); perr != nil {
return perr return perr
} }
why := "you have read-only access, your save was kept as " + filepath.Base(copyPath) why := "you have read-only access, your save was kept as " + filepath.Base(copyPath)
@@ -406,6 +426,8 @@ func (m *Mirror) trackedState(db Database) *fileState {
m.st[db.ID] = st m.st[db.ID] = st
return st return st
} }
// Access came back: the file and what was last agreed are still valid.
st.Retired = false
if st.File == "" { if st.File == "" {
st.File = m.fileNameFor(db) st.File = m.fileNameFor(db)
} }
@@ -413,6 +435,13 @@ func (m *Mirror) trackedState(db Database) *fileState {
want := m.fileNameFor(db) want := m.fileNameFor(db)
old := filepath.Join(m.dir, st.File) old := filepath.Join(m.dir, st.File)
if _, err := os.Stat(old); err == nil && want != st.File { if _, err := os.Stat(old); err == nil && want != st.File {
// Never onto a file that is already there: os.Rename would
// replace it, and it may be someone else's vault.
if _, err := os.Lstat(filepath.Join(m.dir, want)); err == nil {
m.log.Printf("%s: not renaming %s to %s, which already exists", db.Name, st.File, want)
st.Name = db.Name
return st
}
if err := os.Rename(old, filepath.Join(m.dir, want)); err != nil { if err := os.Rename(old, filepath.Join(m.dir, want)); err != nil {
m.log.Printf("%s: rename %s to %s: %v", db.Name, st.File, want, err) m.log.Printf("%s: rename %s to %s: %v", db.Name, st.File, want, err)
return st return st
@@ -425,7 +454,9 @@ func (m *Mirror) trackedState(db Database) *fileState {
} }
// fileNameFor turns a database name into a filename, keeping it unique // fileNameFor turns a database name into a filename, keeping it unique
// within the mirror directory. // within the mirror directory. Names compare without regard to case, since
// macOS and Windows file systems do: "work" and "Work" are one file there,
// and two databases sharing it would upload each other.
func (m *Mirror) fileNameFor(db Database) string { func (m *Mirror) fileNameFor(db Database) string {
base := sanitize(db.Name) base := sanitize(db.Name)
if base == "" { if base == "" {
@@ -433,7 +464,7 @@ func (m *Mirror) fileNameFor(db Database) string {
} }
name := base + ".kdbx" name := base + ".kdbx"
for id, st := range m.st { for id, st := range m.st {
if id != db.ID && st.File == name { if id != db.ID && strings.EqualFold(st.File, name) {
return fmt.Sprintf("%s-%s.kdbx", base, shortID(db.ID)) return fmt.Sprintf("%s-%s.kdbx", base, shortID(db.ID))
} }
} }
@@ -521,7 +552,8 @@ func writeFileAtomic(path string, data []byte) error {
return os.Rename(tmp, path) return os.Rename(tmp, path)
} }
// sanitize keeps a database name usable as a filename on every platform. // sanitize keeps a database name usable as a filename on every platform,
// and out of the names this client makes for itself.
func sanitize(name string) string { func sanitize(name string) string {
var b strings.Builder var b strings.Builder
for _, r := range strings.TrimSpace(name) { for _, r := range strings.TrimSpace(name) {
@@ -533,7 +565,45 @@ func sanitize(name string) string {
b.WriteRune('-') b.WriteRune('-')
} }
} }
return strings.Trim(strings.TrimSuffix(b.String(), ".kdbx"), " .-") out := b.String()
for strings.Contains(out, "..") {
out = strings.ReplaceAll(out, "..", ".")
}
out = strings.Trim(strings.TrimSuffix(out, ".kdbx"), " .-")
// A database called "Work.server-v7" or "Work.conflict-…" would collide
// with the side copies kept for "Work".
for _, reserved := range []string{".server-v", ".conflict-", ".keeporg"} {
out = undotFold(out, reserved)
}
// Windows maps these to devices, whatever the extension.
stem, _, _ := strings.Cut(out, ".")
if windowsDevice[strings.ToUpper(stem)] {
out += "-db"
}
return out
}
var windowsDevice = map[string]bool{
"CON": true, "PRN": true, "AUX": true, "NUL": true,
"COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true,
"COM6": true, "COM7": true, "COM8": true, "COM9": true,
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true,
"LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
}
// undotFold turns the leading dot of every case-insensitive occurrence of
// marker into a dash, keeping the name's own spelling.
func undotFold(s, marker string) string {
lower, low := []byte(strings.ToLower(s)), strings.ToLower(marker)
out := []byte(s)
for i := 0; ; {
j := strings.Index(string(lower[i:]), low)
if j < 0 {
return string(out)
}
out[i+j] = '-'
i += j + len(marker)
}
} }
func shortID(id string) string { func shortID(id string) string {
+90
View File
@@ -0,0 +1,90 @@
package client
import (
"context"
"io"
"log"
"os"
"path/filepath"
"testing"
"time"
)
func TestSanitizeKeepsClearOfTheClientsOwnNames(t *testing.T) {
for in, want := range map[string]string{
"Team Vault": "Team Vault",
"../../etc/passwd": "etc-passwd",
"Work.server-v7": "Work-server-v7",
"Work.CONFLICT-1": "Work-CONFLICT-1",
"nul": "nul-db",
"COM3.backup": "COM3.backup-db",
".keeporg-state": "keeporg-state",
} {
if got := sanitize(in); got != want {
t.Errorf("sanitize(%q) = %q, want %q", in, got, want)
}
}
}
func TestFileNamesDifferingOnlyInCaseDoNotShareAFile(t *testing.T) {
m := &Mirror{st: map[string]*fileState{"a": {Name: "Work", File: "Work.kdbx"}}}
if got := m.fileNameFor(Database{ID: "bbbbbbbb", Name: "work"}); got == "work.kdbx" {
t.Fatalf("a case variant got the same file on a case-insensitive disk: %s", got)
}
}
// The attack: a database the user lost access to leaves its file behind; an
// administrator elsewhere creates an empty database of the same name and
// grants it to the user. The leftover must not be adopted and uploaded.
func TestARetiredFileIsNotAdoptedByANewDatabase(t *testing.T) {
s := newStack(t)
s.sync()
v, _ := s.remote()
if v == 0 {
s.write("Team Vault.kdbx", "the original vault")
s.sync()
}
ctx := context.Background()
if err := s.admin.DeleteDatabase(ctx, s.dbID, password, ""); err != nil {
t.Fatal(err)
}
s.sync()
impostor, err := s.admin.CreateDatabase(ctx, "Team Vault", "everyone", "")
if err != nil {
t.Fatal(err)
}
s.sync()
if _, v, err := s.admin.Pull(ctx, impostor.ID); err != nil || v != 0 {
t.Fatalf("the leftover file was uploaded into the new database: version %d, %v", v, err)
}
if got := s.read("Team Vault.kdbx"); got != "the original vault" {
t.Fatalf("the leftover file changed: %q", got)
}
}
func TestPullDoesNotOverwriteASaveMadeDuringSync(t *testing.T) {
dir := t.TempDir()
m := &Mirror{dir: dir, log: log.New(io.Discard, "", 0)}
path := filepath.Join(dir, "v.kdbx")
os.WriteFile(path, []byte("saved just now"), 0o600)
// The decision was made on the earlier content.
now, _, _ := readFileIfExists(path)
if digest(now) == digest([]byte("earlier")) {
t.Fatal("test setup")
}
s := newStack(t)
m.api = s.admin
if _, err := s.admin.Push(context.Background(), s.dbID, 0, []byte("server copy"), false); err != nil {
t.Fatal(err)
}
m.st, m.status = map[string]*fileState{}, map[string]*Status{}
st := &fileState{File: "v.kdbx"}
err := m.pull(context.Background(), Database{ID: s.dbID, Name: "v"}, st, path, digest([]byte("earlier")))
if err != errChangedDuringSync {
t.Fatalf("pull over a fresh save: %v", err)
}
if b, _ := os.ReadFile(path); string(b) != "saved just now" {
t.Fatalf("the save was overwritten: %q", b)
}
_ = time.Second
}
+9
View File
@@ -3,10 +3,14 @@ package client
import ( import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"strings" "strings"
) )
// maxEventBytes bounds one event's data.
const maxEventBytes = 1 << 20
// sseDecoder reads the subset of the Server-Sent Events format the server // sseDecoder reads the subset of the Server-Sent Events format the server
// emits: "event:" and "data:" lines, one JSON payload per event. // emits: "event:" and "data:" lines, one JSON payload per event.
type sseDecoder struct { type sseDecoder struct {
@@ -47,6 +51,11 @@ func (d *sseDecoder) next() (*Event, error) {
name = strings.TrimSpace(strings.TrimPrefix(line, "event:")) name = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"): case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(strings.TrimPrefix(line, "data:")) data += strings.TrimSpace(strings.TrimPrefix(line, "data:"))
// Events are a few hundred bytes; a stream that keeps adding
// to one is not a KeepOrg server.
if len(data) > maxEventBytes {
return nil, errors.New("event stream: an event exceeds 1 MiB")
}
} }
} }
if err := d.sc.Err(); err != nil { if err := d.sc.Err(); err != nil {
+3
View File
@@ -44,6 +44,9 @@ func deriveKeys(masterSeed []byte, kdf variantDict, password string) (keys, erro
// transformKey applies the file's KDF to the composite key. // transformKey applies the file's KDF to the composite key.
func transformKey(kdf variantDict, composite []byte) ([]byte, error) { func transformKey(kdf variantDict, composite []byte) ([]byte, error) {
if err := checkKDF(kdf); err != nil {
return nil, err
}
uuid := kdf.bytes("$UUID") uuid := kdf.bytes("$UUID")
switch { switch {
case bytes.Equal(uuid, kdfAES): case bytes.Equal(uuid, kdfAES):
+58 -18
View File
@@ -51,18 +51,38 @@ type KDFParams struct {
// every save by every member of the vault. // every save by every member of the vault.
var DefaultKDF = KDFParams{MemoryKiB: 32 * 1024, Iterations: 2, Lanes: 2} var DefaultKDF = KDFParams{MemoryKiB: 32 * 1024, Iterations: 2, Lanes: 2}
// Open decrypts and parses a KDBX 4 file. // Inspect checks a KDBX 4 file's outer header without the password: the
func Open(data []byte, password string) (*Database, error) { // format, and that its key derivation stays within the limits Open accepts.
// It costs nothing, so a server can refuse a file it could not afford to
// open before storing it.
func Inspect(data []byte) error {
_, p, err := readHeader(data)
if err != nil {
return err
}
if p+64 > len(data) {
return errors.New("kdbx: file ends before its integrity hashes")
}
sum := sha256.Sum256(data[:p])
if subtle.ConstantTimeCompare(sum[:], data[p:p+32]) != 1 {
return errors.New("kdbx: header does not match its checksum")
}
return nil
}
// readHeader parses the outer header and returns it with the offset where it
// ends.
func readHeader(data []byte) (*Database, int, error) {
if len(data) < 12 { if len(data) < 12 {
return nil, errors.New("kdbx: file is too short") return nil, 0, errors.New("kdbx: file is too short")
} }
if binary.LittleEndian.Uint32(data[0:]) != signature1 || if binary.LittleEndian.Uint32(data[0:]) != signature1 ||
binary.LittleEndian.Uint32(data[4:]) != signature2 { binary.LittleEndian.Uint32(data[4:]) != signature2 {
return nil, errors.New("kdbx: not a KeePass 2 database") return nil, 0, errors.New("kdbx: not a KeePass 2 database")
} }
version := binary.LittleEndian.Uint32(data[8:]) version := binary.LittleEndian.Uint32(data[8:])
if major := version >> 16; major != 4 { if major := version >> 16; major != 4 {
return nil, unsupported("KDBX %d.%d; this server stores KDBX 4 vaults", return nil, 0, unsupported("KDBX %d.%d; this server stores KDBX 4 vaults",
major, version&0xFFFF) major, version&0xFFFF)
} }
@@ -70,13 +90,13 @@ func Open(data []byte, password string) (*Database, error) {
p := 12 p := 12
for { for {
if p+5 > len(data) { if p+5 > len(data) {
return nil, errors.New("kdbx: truncated header") return nil, 0, errors.New("kdbx: truncated header")
} }
id := data[p] id := data[p]
size := int(binary.LittleEndian.Uint32(data[p+1:])) size := int(binary.LittleEndian.Uint32(data[p+1:]))
p += 5 p += 5
if size < 0 || p+size > len(data) { if size < 0 || p+size > len(data) {
return nil, errors.New("kdbx: truncated header field") return nil, 0, errors.New("kdbx: truncated header field")
} }
field := data[p : p+size] field := data[p : p+size]
p += size p += size
@@ -88,7 +108,7 @@ func Open(data []byte, password string) (*Database, error) {
db.Cipher = append([]byte(nil), field...) db.Cipher = append([]byte(nil), field...)
case hdrCompression: case hdrCompression:
if len(field) != 4 { if len(field) != 4 {
return nil, errors.New("kdbx: malformed compression flags") return nil, 0, errors.New("kdbx: malformed compression flags")
} }
db.Compression = binary.LittleEndian.Uint32(field) db.Compression = binary.LittleEndian.Uint32(field)
case hdrMasterSeed: case hdrMasterSeed:
@@ -97,31 +117,44 @@ func Open(data []byte, password string) (*Database, error) {
case hdrKdfParameters: case hdrKdfParameters:
vd, err := parseVariantDict(field) vd, err := parseVariantDict(field)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
db.KDF = vd db.KDF = vd
case hdrPublicCustomData: case hdrPublicCustomData:
vd, err := parseVariantDict(field) vd, err := parseVariantDict(field)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
db.PublicData = vd db.PublicData = vd
case hdrComment: case hdrComment:
case hdrTransformSeed, hdrTransformRounds, hdrInnerStreamKey3, case hdrTransformSeed, hdrTransformRounds, hdrInnerStreamKey3,
hdrStreamStartBytes, hdrInnerStreamID3: hdrStreamStartBytes, hdrInnerStreamID3:
return nil, unsupported("KDBX 3 header field %d in a KDBX 4 file", id) return nil, 0, unsupported("KDBX 3 header field %d in a KDBX 4 file", id)
} }
} }
headerDone: headerDone:
if len(headerField(data[12:p], hdrMasterSeed)) != 32 {
return nil, 0, errors.New("kdbx: master seed has the wrong length")
}
if len(db.Cipher) != 16 {
return nil, 0, errors.New("kdbx: file names no payload cipher")
}
if err := checkKDF(db.KDF); err != nil {
return nil, 0, err
}
return db, p, nil
}
// Open decrypts and parses a KDBX 4 file.
func Open(data []byte, password string) (*Database, error) {
db, p, err := readHeader(data)
if err != nil {
return nil, err
}
headerBytes := data[:p] headerBytes := data[:p]
masterSeed := headerField(data[12:p], hdrMasterSeed) masterSeed := headerField(data[12:p], hdrMasterSeed)
iv := headerField(data[12:p], hdrEncryptionIV) iv := headerField(data[12:p], hdrEncryptionIV)
if len(masterSeed) != 32 {
return nil, errors.New("kdbx: master seed has the wrong length")
}
if len(db.Cipher) != 16 {
return nil, errors.New("kdbx: file names no payload cipher")
}
if p+64 > len(data) { if p+64 > len(data) {
return nil, errors.New("kdbx: file ends before its integrity hashes") return nil, errors.New("kdbx: file ends before its integrity hashes")
@@ -420,7 +453,14 @@ func gunzip(b []byte) ([]byte, error) {
} }
defer r.Close() defer r.Close()
// A KDBX payload is a password database, not an archive: cap it. // A KDBX payload is a password database, not an archive: cap it.
return io.ReadAll(io.LimitReader(r, 512<<20)) out, err := io.ReadAll(io.LimitReader(r, int64(MaxXMLBytes)+1))
if err != nil {
return nil, err
}
if len(out) > MaxXMLBytes {
return nil, fmt.Errorf("kdbx: the payload decompresses to more than %d MiB", MaxXMLBytes>>20)
}
return out, nil
} }
func randomBytes(n int) []byte { func randomBytes(n int) []byte {
+60
View File
@@ -0,0 +1,60 @@
package kdbx
import (
"bytes"
"fmt"
)
// Limits on what a file may demand of whoever opens it. The server opens
// files that its users upload — a merge decrypts both copies — so a header
// asking for a billion AES rounds, two gibibytes of Argon2 memory or a
// payload that inflates without end would otherwise be a way to stop it.
// The bounds sit well above what KeePassXC, KeePass and KeePassDX choose for
// a one-second unlock on a desktop, and far below what hurts a server.
var (
// MaxAESRounds bounds AES-KDF.
MaxAESRounds uint64 = 30_000_000
// MaxArgon2MemoryKiB bounds Argon2's memory, per derivation.
MaxArgon2MemoryKiB uint64 = 256 << 10
// MaxArgon2Iterations bounds Argon2's passes.
MaxArgon2Iterations uint64 = 64
// MaxArgon2Work bounds memory × passes, in MiB-passes, so a file cannot
// take the maximum of both at once.
MaxArgon2Work uint64 = 4096
// MaxArgon2Lanes bounds Argon2's parallelism.
MaxArgon2Lanes uint64 = 16
// MaxXMLBytes bounds the decompressed payload.
MaxXMLBytes = 128 << 20
// MaxXMLDepth and MaxXMLNodes bound the payload's shape. KeePass nests
// groups a few levels deep and stores thousands of entries, not millions.
MaxXMLDepth = 256
MaxXMLNodes = 4_000_000
)
// checkKDF refuses key derivation parameters beyond the limits.
func checkKDF(kdf variantDict) error {
uuid := kdf.bytes("$UUID")
switch {
case bytes.Equal(uuid, kdfAES):
if r, ok := kdf.uint64("R"); ok && r > MaxAESRounds {
return fmt.Errorf("kdbx: AES-KDF asks for %d rounds, refusing above %d", r, MaxAESRounds)
}
case bytes.Equal(uuid, kdfArgon2d), bytes.Equal(uuid, kdfArgon2id):
mem, _ := kdf.uint64("M")
iter, _ := kdf.uint64("I")
par, _ := kdf.uint64("P")
memKiB := mem / 1024
switch {
case memKiB > MaxArgon2MemoryKiB:
return fmt.Errorf("kdbx: Argon2 asks for %d MiB, refusing above %d", memKiB>>10, MaxArgon2MemoryKiB>>10)
case iter > MaxArgon2Iterations:
return fmt.Errorf("kdbx: Argon2 asks for %d passes, refusing above %d", iter, MaxArgon2Iterations)
case par > MaxArgon2Lanes:
return fmt.Errorf("kdbx: Argon2 asks for %d lanes, refusing above %d", par, MaxArgon2Lanes)
case (memKiB>>10)*iter > MaxArgon2Work:
return fmt.Errorf("kdbx: Argon2 asks for %d MiB × %d passes, refusing above %d MiB-passes",
memKiB>>10, iter, MaxArgon2Work)
}
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package kdbx
import (
"strings"
"testing"
"time"
)
// A file whose header asks for more work than the limits allow is refused
// from its header alone — by Inspect, and by Open before any derivation.
func TestOverLimitKDFIsRefusedBeforeAnyWork(t *testing.T) {
saved := MaxArgon2Iterations
MaxArgon2Iterations = 10_000 // only to be able to write the file
data, err := New("Heavy", KDFParams{MemoryKiB: 8, Iterations: 5_000, Lanes: 1}).Marshal("pw-123456")
MaxArgon2Iterations = saved
if err != nil {
t.Fatal(err)
}
if err := Inspect(data); err == nil || !strings.Contains(err.Error(), "passes") {
t.Fatalf("Inspect = %v", err)
}
start := time.Now()
if _, err := Open(data, "pw-123456"); err == nil {
t.Fatal("Open accepted an over-limit file")
}
if time.Since(start) > 100*time.Millisecond {
t.Fatal("Open did the work before refusing")
}
// Within the limits, Inspect agrees with Open.
ok, err := New("Light", KDFParams{MemoryKiB: 64, Iterations: 1, Lanes: 1}).Marshal("pw-123456")
if err != nil {
t.Fatal(err)
}
if err := Inspect(ok); err != nil {
t.Fatalf("Inspect refused a normal file: %v", err)
}
}
func TestDeepOrHugeXMLIsRefused(t *testing.T) {
deep := strings.Repeat("<a>", MaxXMLDepth+1) + strings.Repeat("</a>", MaxXMLDepth+1)
if _, err := parseXML([]byte(deep)); err == nil || !strings.Contains(err.Error(), "deeper") {
t.Fatalf("deep xml: %v", err)
}
saved := MaxXMLNodes
MaxXMLNodes = 100
defer func() { MaxXMLNodes = saved }()
wide := "<r>" + strings.Repeat("<a/>", 200) + "</r>"
if _, err := parseXML([]byte(wide)); err == nil || !strings.Contains(err.Error(), "elements") {
t.Fatalf("wide xml: %v", err)
}
}
+10
View File
@@ -36,6 +36,7 @@ func parseXML(data []byte) (*Node, error) {
var stack []*Node var stack []*Node
var root *Node var root *Node
var text strings.Builder var text strings.Builder
nodes := 0
for { for {
tok, err := dec.Token() tok, err := dec.Token()
@@ -47,6 +48,15 @@ func parseXML(data []byte) (*Node, error) {
} }
switch t := tok.(type) { switch t := tok.(type) {
case xml.StartElement: case xml.StartElement:
// Bounded here so that nothing after parsing — walking,
// cloning, merging, writing, all recursive — sees a tree
// deeper or bigger than this.
if nodes++; nodes > MaxXMLNodes {
return nil, fmt.Errorf("kdbx: xml: more than %d elements", MaxXMLNodes)
}
if len(stack) >= MaxXMLDepth {
return nil, fmt.Errorf("kdbx: xml: nested deeper than %d", MaxXMLDepth)
}
n := &Node{Name: t.Name.Local} n := &Node{Name: t.Name.Local}
for _, a := range t.Attr { for _, a := range t.Attr {
name := a.Name.Local name := a.Name.Local
+52 -1
View File
@@ -3,7 +3,10 @@ package secret
import ( import (
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/hmac"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -21,6 +24,7 @@ import (
// both holds the vaults. // both holds the vaults.
type Keyring struct { type Keyring struct {
aead cipher.AEAD aead cipher.AEAD
key []byte
} }
// Environment variables that supply the master key. Both are read before // Environment variables that supply the master key. Both are read before
@@ -131,7 +135,54 @@ func NewKeyring(key []byte) (*Keyring, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Keyring{aead: aead}, nil return &Keyring{aead: aead, key: append([]byte(nil), key...)}, nil
}
// Derive returns a keyring for one purpose, keyed by HMAC-SHA256 of the
// master key and the purpose's name. Each use of the master key gets its own
// key this way: a ciphertext made for one purpose does not open under
// another, and no subkey reveals the master key.
func (k *Keyring) Derive(purpose string) *Keyring {
m := hmac.New(sha256.New, k.key)
m.Write([]byte("keeporg/" + purpose))
sub, err := NewKeyring(m.Sum(nil))
if err != nil {
panic("secret: derive: " + err.Error()) // a SHA-256 sum is always 32 bytes
}
return sub
}
// Seal encrypts data with AES-256-GCM under a fresh nonce, authenticating
// aad with it. The output is the nonce followed by the ciphertext.
func (k *Keyring) Seal(aad, data []byte) []byte {
nonce := randBytes(k.aead.NonceSize())
return k.aead.Seal(nonce, nonce, data, aad)
}
// ErrSealed means sealed data did not open: a different key, different
// associated data, or tampering.
var ErrSealed = errors.New("secret: sealed data does not open with this key")
// Open decrypts what Seal produced with the same aad.
func (k *Keyring) Open(aad, sealed []byte) ([]byte, error) {
n := k.aead.NonceSize()
if len(sealed) < n {
return nil, ErrSealed
}
plain, err := k.aead.Open(nil, sealed[:n], sealed[n:], aad)
if err != nil {
return nil, ErrSealed
}
return plain, nil
}
// MAC is a keyed, deterministic digest of value, in hex. It names a row by a
// value without storing the value: equal inputs give equal names, and the
// name says nothing about the input to anyone without the key.
func (k *Keyring) MAC(value string) string {
m := hmac.New(sha256.New, k.key)
m.Write([]byte(value))
return hex.EncodeToString(m.Sum(nil))
} }
// Wrap encrypts a vault secret. The vault's own id is authenticated with it, // Wrap encrypts a vault secret. The vault's own id is authenticated with it,
+21
View File
@@ -228,3 +228,24 @@ func TestSecretFileContentIsValidated(t *testing.T) {
} }
} }
} }
func TestDerivedKeysAreSeparate(t *testing.T) {
k, err := NewKeyring(randBytes(32))
if err != nil {
t.Fatal(err)
}
a, b := k.Derive("records"), k.Derive("blobs")
sealed := a.Seal([]byte("row-1"), []byte("hello"))
if got, err := a.Open([]byte("row-1"), sealed); err != nil || string(got) != "hello" {
t.Fatalf("open = %q, %v", got, err)
}
if _, err := b.Open([]byte("row-1"), sealed); err == nil {
t.Fatal("a ciphertext opened under another purpose's key")
}
if _, err := a.Open([]byte("row-2"), sealed); err == nil {
t.Fatal("a ciphertext opened under another row's name")
}
if a.MAC("x") != k.Derive("records").MAC("x") || a.MAC("x") == b.MAC("x") {
t.Fatal("MAC is not deterministic per purpose")
}
}
+25 -41
View File
@@ -27,8 +27,8 @@ var (
// is no password to change. // is no password to change.
errNoPassword = errors.New("this account signs in through single sign-on and has no password here") errNoPassword = errors.New("this account signs in through single sign-on and has no password here")
// errReauth asks for the password and, once enrolled, the one-time code, // errReauth asks for the password and, once enrolled, the one-time code,
// before a change that would weaken how the account signs in. A live // before a change to how the account signs in. A live session is what
// session is what an unlocked laptop leaves lying around. // an unlocked laptop leaves lying around.
errReauth = errors.New("confirm with your password and one-time code") errReauth = errors.New("confirm with your password and one-time code")
) )
@@ -104,24 +104,12 @@ func revokeOtherSessions(st *store.State, userID, keep string) int {
return n return n
} }
// reauth checks the caller's password, and their one-time code when one is
// enrolled, for a change that weakens or replaces how the account signs in.
func reauth(st *store.State, u *store.User, password, code string) error {
if u.PassHash == "" {
return errNoPassword
}
if password == "" {
return errReauth
}
_, err := checkPassword(st, u.Username, password, code)
return err
}
// beginTOTP starts enrolling a second factor and returns its secret. Until // beginTOTP starts enrolling a second factor and returns its secret. Until
// confirmTOTP sees a code from it, the account signs in as before. Replacing // confirmTOTP sees a code from it, the account signs in as before. The
// a factor that is already active needs the password and the current code, // caller has re-proved the password (and any current code): turning a
// because a new secret disarms the old one. // factor on from a stolen session would lock the owner out, and replacing
func beginTOTP(st *store.State, userID, password, code string) (string, error) { // one disarms the old.
func beginTOTP(st *store.State, userID string) (string, error) {
u := st.Users[userID] u := st.Users[userID]
if u == nil { if u == nil {
return "", errNoSuchSession return "", errNoSuchSession
@@ -129,16 +117,12 @@ func beginTOTP(st *store.State, userID, password, code string) (string, error) {
if u.PassHash == "" { if u.PassHash == "" {
return "", errNoPassword return "", errNoPassword
} }
if u.TOTPConfirmed { u.TOTPSecret, u.TOTPConfirmed, u.TOTPLastStep = totp.NewSecret(), false, 0
if err := reauth(st, u, password, code); err != nil {
return "", err
}
}
u.TOTPSecret, u.TOTPConfirmed = totp.NewSecret(), false
return u.TOTPSecret, nil return u.TOTPSecret, nil
} }
// confirmTOTP turns the second factor on, once a code from it checks out. // confirmTOTP turns the second factor on, once a code from it checks out.
// That code is spent: it cannot also sign in.
func confirmTOTP(st *store.State, userID, code string) error { func confirmTOTP(st *store.State, userID, code string) error {
u := st.Users[userID] u := st.Users[userID]
if u == nil || u.TOTPSecret == "" { if u == nil || u.TOTPSecret == "" {
@@ -147,10 +131,11 @@ func confirmTOTP(st *store.State, userID, code string) error {
if u.TOTPConfirmed { if u.TOTPConfirmed {
return errors.New("the second factor is already on") return errors.New("the second factor is already on")
} }
if !totp.Check(u.TOTPSecret, code, time.Now()) { step, ok := totp.Match(u.TOTPSecret, code, time.Now())
if !ok {
return errTOTPInvalid return errTOTPInvalid
} }
u.TOTPConfirmed = true u.TOTPConfirmed, u.TOTPLastStep = true, step
return nil return nil
} }
@@ -161,9 +146,9 @@ func cancelTOTP(st *store.State, userID string) {
} }
} }
// disableTOTP turns the second factor off, behind the password and a // disableTOTP turns the second factor off. The caller has re-proved the
// current code. // password and a current code.
func disableTOTP(st *store.State, userID, password, code string) error { func disableTOTP(st *store.State, userID string) error {
u := st.Users[userID] u := st.Users[userID]
if u == nil { if u == nil {
return errNoSuchSession return errNoSuchSession
@@ -171,10 +156,7 @@ func disableTOTP(st *store.State, userID, password, code string) error {
if !u.TOTPConfirmed { if !u.TOTPConfirmed {
return errors.New("no second factor is on") return errors.New("no second factor is on")
} }
if err := reauth(st, u, password, code); err != nil { u.TOTPSecret, u.TOTPConfirmed, u.TOTPLastStep = "", false, 0
return err
}
u.TOTPSecret, u.TOTPConfirmed = "", false
return nil return nil
} }
@@ -182,6 +164,9 @@ func disableTOTP(st *store.State, userID, password, code string) error {
// failures are 403, not 401: a client that drops its token on 401 must not // failures are 403, not 401: a client that drops its token on 401 must not
// sign itself out over a mistyped password. // sign itself out over a mistyped password.
func failAccount(w http.ResponseWriter, err error) { func failAccount(w http.ResponseWriter, err error) {
if writeThrottled(w, err) {
return
}
switch { switch {
case errors.Is(err, errNoSuchSession): case errors.Is(err, errNoSuchSession):
fail(w, http.StatusNotFound, "not_found", err.Error()) fail(w, http.StatusNotFound, "not_found", err.Error())
@@ -190,7 +175,7 @@ func failAccount(w http.ResponseWriter, err error) {
case errors.Is(err, errTOTPInvalid): case errors.Is(err, errTOTPInvalid):
fail(w, http.StatusForbidden, "totp_invalid", "the one-time code was rejected") fail(w, http.StatusForbidden, "totp_invalid", "the one-time code was rejected")
case errors.Is(err, errBadCredentials): case errors.Is(err, errBadCredentials):
fail(w, http.StatusForbidden, "bad_credentials", "the password is wrong") fail(w, http.StatusForbidden, "bad_credentials", "the password or one-time code is wrong")
case errors.Is(err, errNoPassword): case errors.Is(err, errNoPassword):
fail(w, http.StatusConflict, "no_password", err.Error()) fail(w, http.StatusConflict, "no_password", err.Error())
default: default:
@@ -228,8 +213,7 @@ func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, map[string]int{"revoked": n}) writeJSON(w, http.StatusOK, map[string]int{"revoked": n})
} }
// reauthRequest is the body of a change that may need re-authentication. // reauthRequest is the body of a change that needs re-authentication.
// It is optional where nothing is enrolled yet.
type reauthRequest struct { type reauthRequest struct {
Password string `json:"password"` Password string `json:"password"`
TOTP string `json:"totp"` TOTP string `json:"totp"`
@@ -241,9 +225,9 @@ func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *sto
return return
} }
var sec string var sec string
if err := s.st.Update(func(st *store.State) error { if err := s.reconfirm(r, u, req.Password, req.TOTP, func(st *store.State, u *store.User) error {
var err error var err error
sec, err = beginTOTP(st, u.ID, req.Password, req.TOTP) sec, err = beginTOTP(st, u.ID)
return err return err
}); err != nil { }); err != nil {
failAccount(w, err) failAccount(w, err)
@@ -280,8 +264,8 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request, u *st
if !readJSON(w, r, &req) { if !readJSON(w, r, &req) {
return return
} }
if err := s.st.Update(func(st *store.State) error { if err := s.reconfirm(r, u, req.Password, req.TOTP, func(st *store.State, u *store.User) error {
return disableTOTP(st, u.ID, req.Password, req.TOTP) return disableTOTP(st, u.ID)
}); err != nil { }); err != nil {
failAccount(w, err) failAccount(w, err)
return return
+57 -17
View File
@@ -13,6 +13,34 @@ import (
"cc.tiredsysadmin.keeporg/internal/totp" "cc.tiredsysadmin.keeporg/internal/totp"
) )
// codeAt is the one-time code d steps from now. Each code is good once, so
// a test that needs several uses inside one 30-second step takes them at
// -1, 0 and +1, all within the accepted skew.
func codeAt(t *testing.T, sec string, d int) string {
t.Helper()
c, err := totp.Code(sec, time.Now().Add(time.Duration(d)*30*time.Second))
if err != nil {
t.Fatal(err)
}
return c
}
// enrollTOTP turns the second factor on for the token's account, spending
// the code one step back, and returns the secret.
func (h *harness) enrollTOTP(t *testing.T, tok, password string) string {
t.Helper()
status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, map[string]string{"password": password})
if status != http.StatusOK {
t.Fatalf("enroll: %d %v", status, body)
}
sec := body["secret"].(string)
if status, body := h.call("POST", "/api/v1/auth/totp/confirm", tok,
map[string]string{"code": codeAt(t, sec, -1)}); status != http.StatusOK {
t.Fatalf("confirm: %d %v", status, body)
}
return sec
}
func sessionList(t *testing.T, h *harness, token string) []map[string]any { func sessionList(t *testing.T, h *harness, token string) []map[string]any {
t.Helper() t.Helper()
status, body := h.call("GET", "/api/v1/auth/sessions", token, nil) status, body := h.call("GET", "/api/v1/auth/sessions", token, nil)
@@ -75,16 +103,13 @@ func TestReplacingOrRemovingTheSecondFactorNeedsThePassword(t *testing.T) {
h := newHarness(t) h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "") tok, _, _ := h.login("admin", adminPassword, "")
// Turning it on needs nothing beyond the session: it only adds a check. // Even turning it on needs the password: on a stolen session it would
status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil) // lock the owner out.
if status != http.StatusOK { if status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil); status != http.StatusForbidden {
t.Fatalf("enroll: %d %v", status, body) t.Fatalf("enroll without the password: %d %v", status, body)
}
sec := body["secret"].(string)
code, _ := totp.Code(sec, time.Now())
if status, _ := h.call("POST", "/api/v1/auth/totp/confirm", tok, map[string]string{"code": code}); status != http.StatusOK {
t.Fatalf("confirm: %d", status)
} }
sec := h.enrollTOTP(t, tok, adminPassword)
code := codeAt(t, sec, 0)
// A session alone can no longer swap the secret or turn it off. // A session alone can no longer swap the secret or turn it off.
if status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil); status != http.StatusForbidden || body["code"] != "reauth_required" { if status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil); status != http.StatusForbidden || body["code"] != "reauth_required" {
@@ -98,6 +123,11 @@ func TestReplacingOrRemovingTheSecondFactorNeedsThePassword(t *testing.T) {
map[string]string{"password": "wrong-password", "totp": code}); status != http.StatusForbidden || body["code"] != "bad_credentials" { map[string]string{"password": "wrong-password", "totp": code}); status != http.StatusForbidden || body["code"] != "bad_credentials" {
t.Fatalf("disable with a wrong password: %d %v", status, body) t.Fatalf("disable with a wrong password: %d %v", status, body)
} }
// A wrong code reads the same as a wrong password.
if status, body := h.call("DELETE", "/api/v1/auth/totp", tok,
map[string]string{"password": adminPassword, "totp": "000000"}); status != http.StatusForbidden || body["code"] != "bad_credentials" {
t.Fatalf("disable with a wrong code: %d %v", status, body)
}
var on bool var on bool
h.srv.Store().Read(func(st *store.State) { on = st.Users["admin"].TOTPConfirmed }) h.srv.Store().Read(func(st *store.State) { on = st.Users["admin"].TOTPConfirmed })
if !on { if !on {
@@ -171,6 +201,11 @@ func TestAccountPageSetsUpTheSecondFactor(t *testing.T) {
csrf := b.signIn() csrf := b.signIn()
_, page := b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-begin"}}) _, page := b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-begin"}})
if !strings.Contains(page, "Confirm with your password") {
t.Fatalf("set up without the password:\n%s", page)
}
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-begin"},
"password": {adminPassword}})
if !strings.Contains(page, `href="otpauth://totp/`) { if !strings.Contains(page, `href="otpauth://totp/`) {
t.Fatalf("no authenticator link:\n%s", page) t.Fatalf("no authenticator link:\n%s", page)
} }
@@ -184,8 +219,7 @@ func TestAccountPageSetsUpTheSecondFactor(t *testing.T) {
if !strings.Contains(page, "rejected") { if !strings.Contains(page, "rejected") {
t.Fatalf("a wrong code was not refused:\n%s", page) t.Fatalf("a wrong code was not refused:\n%s", page)
} }
code, _ := totp.Code(sec, time.Now()) _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {codeAt(t, sec, -1)}})
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {code}})
if !strings.Contains(page, "Two-factor authentication is on") { if !strings.Contains(page, "Two-factor authentication is on") {
t.Fatalf("not turned on:\n%s", page) t.Fatalf("not turned on:\n%s", page)
} }
@@ -199,7 +233,7 @@ func TestAccountPageSetsUpTheSecondFactor(t *testing.T) {
t.Fatalf("turned off without a code:\n%s", page) t.Fatalf("turned off without a code:\n%s", page)
} }
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-disable"}, _, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-disable"},
"password": {adminPassword}, "totp": {code}}) "password": {adminPassword}, "totp": {codeAt(t, sec, 0)}})
if !strings.Contains(page, "Two-factor authentication is off") { if !strings.Contains(page, "Two-factor authentication is off") {
t.Fatalf("not turned off:\n%s", page) t.Fatalf("not turned off:\n%s", page)
} }
@@ -257,6 +291,17 @@ func TestBrowserLabel(t *testing.T) {
// makeUser adds a password account and returns a session token for it. // makeUser adds a password account and returns a session token for it.
func (h *harness) makeUser(name, password string) string { func (h *harness) makeUser(name, password string) string {
h.t.Helper()
h.makeUserNoLogin(name, password)
tok, status, body := h.login(name, password, "")
if status != http.StatusOK {
h.t.Fatalf("login %s: %d %v", name, status, body)
}
return tok
}
// makeUserNoLogin adds a password account.
func (h *harness) makeUserNoLogin(name, password string) {
h.t.Helper() h.t.Helper()
hash, err := secret.Hash(password) hash, err := secret.Hash(password)
if err != nil { if err != nil {
@@ -268,9 +313,4 @@ func (h *harness) makeUser(name, password string) string {
}); err != nil { }); err != nil {
h.t.Fatal(err) h.t.Fatal(err)
} }
tok, status, body := h.login(name, password, "")
if status != http.StatusOK {
h.t.Fatalf("login %s: %d %v", name, status, body)
}
return tok
} }
+81 -57
View File
@@ -23,14 +23,19 @@ var errForbidden = errors.New("forbidden")
// cases stay distinct so a client knows to prompt for the factor it is // cases stay distinct so a client knows to prompt for the factor it is
// missing rather than reporting a flat refusal. // missing rather than reporting a flat refusal.
func denyAuth(w http.ResponseWriter, err error) { func denyAuth(w http.ResponseWriter, err error) {
if writeThrottled(w, err) {
return
}
switch { switch {
case errors.Is(err, errNoDatabase):
fail(w, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, errTOTPRequired): case errors.Is(err, errTOTPRequired):
fail(w, http.StatusUnauthorized, "totp_required", "second factor required") fail(w, http.StatusUnauthorized, "totp_required", "your one-time code is required for this")
case errors.Is(err, errTOTPInvalid): case errors.Is(err, errReauth):
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected") fail(w, http.StatusUnauthorized, "bad_credentials", "your own account password is required for this")
case errors.Is(err, errBadCredentials): case errors.Is(err, errBadCredentials):
fail(w, http.StatusUnauthorized, "bad_credentials", fail(w, http.StatusUnauthorized, "bad_credentials",
"your own account password is required for this") "your own account password or one-time code is wrong")
default: default:
deny(w, err) deny(w, err)
} }
@@ -41,6 +46,8 @@ func deny(w http.ResponseWriter, err error) {
switch { switch {
case err == nil: case err == nil:
return return
case errors.Is(err, errNoDatabase):
fail(w, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, errForbidden): case errors.Is(err, errForbidden):
fail(w, http.StatusForbidden, "forbidden", "you do not administer that object") fail(w, http.StatusForbidden, "forbidden", "you do not administer that object")
default: default:
@@ -215,16 +222,27 @@ func removeMember(st *store.State, u *store.User, groupID string, refs ...string
// ---- grants ---- // ---- grants ----
// adminsDatabase checks that u administers a database. One without any
// access cannot tell a database that is not theirs from one that does not
// exist; one who can see it is told it is not theirs to change.
func adminsDatabase(st *store.State, u *store.User, dbID string) error {
perm := st.DatabasePerm(u.ID, dbID)
switch {
case st.Databases[dbID] == nil || perm == store.PermNone:
return errNoDatabase
case perm != store.PermAdmin:
return errForbidden
}
return nil
}
func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.Perm) error { func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.Perm) error {
if st.Databases[dbID] == nil { if err := adminsDatabase(st, u, dbID); err != nil {
return errors.New("no such database") return err
} }
if st.Groups[groupID] == nil { if st.Groups[groupID] == nil {
return errors.New("no such group") return errors.New("no such group")
} }
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
return errForbidden
}
if !perm.Valid() { if !perm.Valid() {
return errors.New("perm must be read, write or admin") return errors.New("perm must be read, write or admin")
} }
@@ -239,11 +257,8 @@ func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.P
} }
func removeGrant(st *store.State, u *store.User, groupID, dbID string) error { func removeGrant(st *store.State, u *store.User, groupID, dbID string) error {
if st.Databases[dbID] == nil { if err := adminsDatabase(st, u, dbID); err != nil {
return errors.New("no such database") return err
}
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
return errForbidden
} }
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { st.Grants = filterGrants(st.Grants, func(g store.Grant) bool {
return g.GroupID != groupID || g.DatabaseID != dbID return g.GroupID != groupID || g.DatabaseID != dbID
@@ -445,13 +460,10 @@ func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *
} }
var out dbView var out dbView
err := s.st.Update(func(st *store.State) error { err := s.st.Update(func(st *store.State) error {
if err := adminsDatabase(st, u, id); err != nil {
return err
}
db := st.Databases[id] db := st.Databases[id]
if db == nil {
return errors.New("no such database")
}
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
return errForbidden
}
if req.Name != nil { if req.Name != nil {
if strings.TrimSpace(*req.Name) == "" { if strings.TrimSpace(*req.Name) == "" {
return errors.New("name may not be empty") return errors.New("name may not be empty")
@@ -483,49 +495,48 @@ func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *
// did not type the name of the thing it is about to destroy. // did not type the name of the thing it is about to destroy.
var errConfirmName = errors.New("type the name exactly to confirm") var errConfirmName = errors.New("type the name exactly to confirm")
// confirmDeletion re-proves who the caller is before something irreversible. // errNoDatabase is a database that does not exist or that the caller does
// Holding a session is enough to read and write a vault; it is not enough to // not administer; the two are not told apart, so neither answer says that a
// destroy every version of one, because a session is what an unlocked laptop // database exists.
// leaves lying around. var errNoDatabase = errors.New("no such database")
//
// An account that signs in only through an identity provider has no password
// to type. Refusing those accounts would mean a deployment where nobody can
// delete anything, so they confirm by typing the exact name instead. That is
// a weaker gate and it is deliberately the fallback, not the rule.
func confirmDeletion(st *store.State, u *store.User, name, password, totpCode, typed string) error {
target := st.Users[u.ID]
if target == nil {
return errBadCredentials
}
if target.PassHash == "" {
if strings.TrimSpace(typed) != name {
return errConfirmName
}
return nil
}
_, err := checkPassword(st, target.Username, password, totpCode)
return err
}
// deleteDatabase removes a database, its grants and every stored version of // deleteDatabase removes a database, its grants and every stored version of
// the file. Clients keep whatever they already have on disk: the server can // the file. Clients keep whatever they already have on disk: the server can
// stop tracking a vault, but it has no business reaching into somebody's // stop tracking a vault, but it has no business reaching into somebody's
// home directory. // home directory.
func (s *Server) deleteDatabase(u *store.User, id, password, totpCode, typed string) (string, error) { //
// It re-proves who the caller is first. Holding a session is enough to read
// and write a vault; it is not enough to destroy every version of one,
// because a session is what an unlocked laptop leaves lying around. The
// password is checked like a sign-in — outside the store's lock and against
// the attempt limits — and only for a caller who administers the database,
// so it is not a password oracle for anyone else.
//
// An account that signs in only through an identity provider has no password
// to type. Refusing those accounts would mean a deployment where nobody can
// delete anything, so they confirm by typing the exact name instead. That is
// a weaker gate and it is deliberately the fallback, not the rule.
func (s *Server) deleteDatabase(r *http.Request, u *store.User, id, password, totpCode, typed string) (string, error) {
var ( var (
name string name string
watchers []string hasPassword bool
) )
err := s.st.Update(func(st *store.State) error { s.st.Read(func(st *store.State) {
if db := st.Databases[id]; db != nil && st.DatabasePerm(u.ID, id) == store.PermAdmin {
name = db.Name
}
if cur := st.Users[u.ID]; cur != nil {
hasPassword = cur.PassHash != ""
}
})
if name == "" {
return "", errNoDatabase
}
var watchers []string
remove := func(st *store.State, _ *store.User) error {
db := st.Databases[id] db := st.Databases[id]
if db == nil { if db == nil || st.DatabasePerm(u.ID, id) != store.PermAdmin {
return errors.New("no such database") return errNoDatabase
}
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
return errForbidden
}
if cerr := confirmDeletion(st, u, db.Name, password, totpCode, typed); cerr != nil {
return cerr
} }
name = db.Name name = db.Name
// Taken before the delete: the people to tell are the ones who are // Taken before the delete: the people to tell are the ones who are
@@ -534,7 +545,16 @@ func (s *Server) deleteDatabase(u *store.User, id, password, totpCode, typed str
delete(st.Databases, id) delete(st.Databases, id)
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id }) st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id })
return nil return nil
}) }
var err error
if !hasPassword {
if strings.TrimSpace(typed) != name {
return "", errConfirmName
}
err = s.st.Update(func(st *store.State) error { return remove(st, u) })
} else {
err = s.reconfirm(r, u, password, totpCode, remove)
}
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -555,7 +575,7 @@ func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u
if r.ContentLength > 0 && !readJSON(w, r, &req) { if r.ContentLength > 0 && !readJSON(w, r, &req) {
return return
} }
if _, err := s.deleteDatabase(u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil { if _, err := s.deleteDatabase(r, u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil {
denyAuth(w, err) denyAuth(w, err)
return return
} }
@@ -777,7 +797,11 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *stor
out := []userView{} out := []userView{}
s.st.Read(func(st *store.State) { s.st.Read(func(st *store.State) {
for _, target := range visibleUsers(st, u) { for _, target := range visibleUsers(st, u) {
out = append(out, view(target)) v := view(target)
if !u.ServerAdmin && target.ID != u.ID {
v.Email, v.TOTPEnabled, v.ServerAdmin = "", false, false
}
out = append(out, v)
} }
}) })
writeJSON(w, http.StatusOK, map[string]any{"users": out}) writeJSON(w, http.StatusOK, map[string]any{"users": out})
+56 -49
View File
@@ -10,7 +10,6 @@ import (
"cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
) )
// MinPasswordLen is the shortest password the server will store. // MinPasswordLen is the shortest password the server will store.
@@ -19,33 +18,31 @@ const MinPasswordLen = 10
// deviceTTL bounds how long a pending device login stays open. // deviceTTL bounds how long a pending device login stays open.
const deviceTTL = 10 * time.Minute const deviceTTL = 10 * time.Minute
// maxPendingDevices bounds the device logins anyone can open without an
// account. Each is a row in the state, which every write copies.
const maxPendingDevices = 500
// errBusy means too many device logins are pending.
var errBusy = errors.New("too many sign-ins are pending; try again in a few minutes")
// pruneDevices drops expired device logins and reports whether there is
// room for another.
func pruneDevices(st *store.State) bool {
now := time.Now()
for k, d := range st.Devices {
if now.After(d.ExpiresAt) {
delete(st.Devices, k)
}
}
return len(st.Devices) < maxPendingDevices
}
var ( var (
errBadCredentials = errors.New("bad credentials") errBadCredentials = errors.New("bad credentials")
errTOTPRequired = errors.New("totp required") errTOTPRequired = errors.New("totp required")
errTOTPInvalid = errors.New("totp invalid") errTOTPInvalid = errors.New("totp invalid")
) )
// checkPassword validates a username, password and second factor against the
// state. It is the one place password login is decided.
func checkPassword(st *store.State, username, password, code string) (*store.User, error) {
u := st.UserByName(username)
if u == nil || u.Disabled || u.PassHash == "" {
return nil, errBadCredentials
}
if !secret.Verify(u.PassHash, password) {
return nil, errBadCredentials
}
if u.TOTPConfirmed {
if strings.TrimSpace(code) == "" {
return nil, errTOTPRequired
}
if !totp.Check(u.TOTPSecret, code, time.Now()) {
return nil, errTOTPInvalid
}
}
return u, nil
}
type loginRequest struct { type loginRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
@@ -78,15 +75,14 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
var resp loginResponse var resp loginResponse
err := s.st.Update(func(st *store.State) error { err := s.signIn(r, req.Username, req.Password, req.TOTP, func(st *store.State, u *store.User) error {
u, err := checkPassword(st, req.Username, req.Password, req.TOTP) sess := s.newSession(st, u.ID, clampLabel(req.Label))
if err != nil {
return err
}
sess := s.newSession(st, u.ID, req.Label)
resp = loginResponse{Token: sess.Token, ExpiresAt: sess.ExpiresAt, User: view(u)} resp = loginResponse{Token: sess.Token, ExpiresAt: sess.ExpiresAt, User: view(u)}
return nil return nil
}) })
if writeThrottled(w, err) {
return
}
switch { switch {
case errors.Is(err, errTOTPRequired): case errors.Is(err, errTOTPRequired):
fail(w, http.StatusUnauthorized, "totp_required", "second factor required") fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
@@ -151,19 +147,31 @@ func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
"return_url must use the "+returnScheme+": scheme") "return_url must use the "+returnScheme+": scheme")
return return
} }
if wait := s.limits.take("start:" + s.clientIP(r)); wait > 0 {
writeThrottled(w, &errThrottled{RetryAfter: wait})
return
}
d := &store.DeviceAuth{ d := &store.DeviceAuth{
DeviceCode: secret.Token(), DeviceCode: secret.Token(),
UserCode: secret.UserCode(), UserCode: secret.UserCode(),
Label: req.Label, Label: clampLabel(req.Label),
ReturnURL: req.ReturnURL, ReturnURL: req.ReturnURL,
CreatedAt: time.Now(), CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(deviceTTL), ExpiresAt: time.Now().Add(deviceTTL),
} }
if err := s.st.Update(func(st *store.State) error { if err := s.st.Update(func(st *store.State) error {
if !pruneDevices(st) {
return errBusy
}
st.Devices[d.DeviceCode] = d st.Devices[d.DeviceCode] = d
return nil return nil
}); err != nil { }); err != nil {
fail(w, http.StatusInternalServerError, "internal", err.Error()) if errors.Is(err, errBusy) {
fail(w, http.StatusServiceUnavailable, "busy", err.Error())
return
}
s.log.Printf("device start: %v", err)
fail(w, http.StatusInternalServerError, "internal", "could not start the sign-in")
return return
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
@@ -237,20 +245,20 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
} }
code := strings.ToUpper(strings.TrimSpace(r.FormValue("code"))) code := strings.ToUpper(strings.TrimSpace(r.FormValue("code")))
var returnURL string var returnURL string
err := s.st.Update(func(st *store.State) error { err := s.signIn(r, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"),
func(st *store.State, u *store.User) error {
d := st.DeviceByUserCode(code) d := st.DeviceByUserCode(code)
if d == nil || time.Now().After(d.ExpiresAt) { if d == nil || time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
return errors.New("unknown or expired code") return errors.New("unknown or expired code")
} }
returnURL = d.ReturnURL returnURL = d.ReturnURL
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"))
if err != nil {
return err
}
d.SessionToken = s.newSession(st, u.ID, d.Label).Token d.SessionToken = s.newSession(st, u.ID, d.Label).Token
return nil return nil
}) })
var throttled *errThrottled
switch { switch {
case errors.As(err, &throttled):
s.renderDevice(w, code, throttled.Error())
case errors.Is(err, errTOTPRequired): case errors.Is(err, errTOTPRequired):
s.renderDevice(w, code, "This account requires a one-time code.") s.renderDevice(w, code, "This account requires a one-time code.")
case errors.Is(err, errTOTPInvalid): case errors.Is(err, errTOTPInvalid):
@@ -280,21 +288,19 @@ func validReturnURL(raw string) bool {
// session, the second factor is required when one is enrolled, and every // session, the second factor is required when one is enrolled, and every
// other session of that user is revoked, because a password change is what // other session of that user is revoked, because a password change is what
// someone does when they think a session is no longer theirs. // someone does when they think a session is no longer theirs.
func changePassword(st *store.State, username, current, totpCode, next, keepToken string) error { func (s *Server) changePassword(r *http.Request, u *store.User, current, code, next, keepToken string) error {
u, err := checkPassword(st, username, current, totpCode)
if err != nil {
return err
}
if len(next) < MinPasswordLen { if len(next) < MinPasswordLen {
return fmt.Errorf("the new password must be at least %d characters", MinPasswordLen) return fmt.Errorf("the new password must be at least %d characters", MinPasswordLen)
} }
if next == current { if next == current {
return errors.New("the new password is the same as the old one") return errors.New("the new password is the same as the old one")
} }
// Hashed before the write, for the same reason verify checks outside it.
hash, err := secret.Hash(next) hash, err := secret.Hash(next)
if err != nil { if err != nil {
return err return err
} }
return s.reconfirm(r, u, current, code, func(st *store.State, u *store.User) error {
u.PassHash = hash u.PassHash = hash
for token, sess := range st.Sessions { for token, sess := range st.Sessions {
if sess.UserID == u.ID && token != keepToken { if sess.UserID == u.ID && token != keepToken {
@@ -302,6 +308,7 @@ func changePassword(st *store.State, username, current, totpCode, next, keepToke
} }
} }
return nil return nil
})
} }
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u *store.User) { func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u *store.User) {
@@ -313,17 +320,17 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u
if !readJSON(w, r, &req) { if !readJSON(w, r, &req) {
return return
} }
err := s.st.Update(func(st *store.State) error { err := s.changePassword(r, u, req.CurrentPassword, req.TOTP, req.NewPassword, bearer(r))
return changePassword(st, u.Username, req.CurrentPassword, req.TOTP, if writeThrottled(w, err) {
req.NewPassword, bearer(r)) return
}) }
switch { switch {
case errors.Is(err, errTOTPRequired): case errors.Is(err, errTOTPRequired):
fail(w, http.StatusUnauthorized, "totp_required", "second factor required") fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
case errors.Is(err, errTOTPInvalid): case errors.Is(err, errBadCredentials), errors.Is(err, errReauth):
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected") fail(w, http.StatusUnauthorized, "bad_credentials", "the current password or one-time code is wrong")
case errors.Is(err, errBadCredentials): case errors.Is(err, errNoPassword):
fail(w, http.StatusUnauthorized, "bad_credentials", "the current password is wrong") fail(w, http.StatusUnauthorized, "bad_credentials", err.Error())
case err != nil: case err != nil:
fail(w, http.StatusBadRequest, "bad_request", err.Error()) fail(w, http.StatusBadRequest, "bad_request", err.Error())
default: default:
+353
View File
@@ -0,0 +1,353 @@
package server
import (
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
)
// Proving who someone is: every password and one-time code the server
// checks goes through verify, wherever it was typed — the API, the sign-in
// form, the device page, a password change, a second-factor change or a
// vault deletion. Two things follow from that being one function.
//
// The password hash is checked outside the store's lock. It is deliberately
// slow, and under the write lock it stalled every other request, so a few
// guesses a second were a denial of service. verify reads what it needs,
// checks it, and commit re-checks inside the write that nothing changed in
// between.
//
// Failures are counted per client address and per account, and a client or
// account over its budget is refused before any hash is computed. The
// counts live in memory: a restart forgets them, which costs an attacker a
// restart they cannot cause.
// errThrottled means too many failures; RetryAfter says how long to wait.
type errThrottled struct{ RetryAfter time.Duration }
func (e *errThrottled) Error() string {
return fmt.Sprintf("too many failed attempts; try again in %s",
e.RetryAfter.Round(time.Minute))
}
// credentials is a password, and code, that checked out against an account
// as it stood when verify read it.
type credentials struct {
userID string
hash string
step int64 // the one-time code's time step, when one was needed
totp bool
}
// dummyHash is verified against when there is no real one, so that an
// unknown, disabled or password-less account takes as long to refuse as a
// wrong password: the timing would otherwise list the usernames.
var dummyHash = func() string {
h, err := secret.Hash("keeporg timing equaliser, never a real password")
if err != nil {
panic(err)
}
return h
}()
// verify checks a username, password and one-time code.
func (s *Server) verify(r *http.Request, username, password, code string) (*credentials, error) {
ip := "ip:" + s.clientIP(r)
acct := "user:" + strings.ToLower(strings.TrimSpace(username))
if wait := s.limits.blocked(ip, acct); wait > 0 {
return nil, &errThrottled{RetryAfter: wait}
}
var (
u store.User
found bool
)
s.st.Read(func(st *store.State) {
if p := st.UserByName(username); p != nil {
u, found = *p, true
}
})
if !found || u.Disabled || u.PassHash == "" {
secret.Verify(dummyHash, password)
s.limits.fail(ip, acct)
return nil, errBadCredentials
}
if !secret.Verify(u.PassHash, password) {
s.limits.fail(ip, acct)
return nil, errBadCredentials
}
c := &credentials{userID: u.ID, hash: u.PassHash}
if u.TOTPConfirmed {
if strings.TrimSpace(code) == "" {
return nil, errTOTPRequired
}
step, ok := totp.Match(u.TOTPSecret, code, time.Now())
if !ok || step <= u.TOTPLastStep {
s.limits.fail(ip, acct)
return nil, errTOTPInvalid
}
c.step, c.totp = step, true
}
s.limits.reset(acct)
return c, nil
}
// commit re-checks verified credentials inside a write: the account must
// still exist, be enabled and have the same password, and the one-time code
// is spent. It returns the account to act on.
func (c *credentials) commit(st *store.State) (*store.User, error) {
u := st.Users[c.userID]
if u == nil || u.Disabled || u.PassHash != c.hash {
return nil, errBadCredentials
}
if c.totp && u.TOTPConfirmed {
if c.step <= u.TOTPLastStep {
return nil, errTOTPInvalid
}
u.TOTPLastStep = c.step
}
return u, nil
}
// signIn verifies credentials and, inside one write, re-checks them and runs
// then on the account. It is the shape of every sign-in.
func (s *Server) signIn(r *http.Request, username, password, code string,
then func(st *store.State, u *store.User) error) error {
c, err := s.verify(r, username, password, code)
if err != nil {
return err
}
return s.st.Update(func(st *store.State) error {
u, err := c.commit(st)
if err != nil {
return err
}
return then(st, u)
})
}
// reconfirm proves, for a change that weakens or replaces how an account
// signs in, that the person holding the session also holds the password and
// the current code. Unlike a sign-in it does not say which of the two was
// wrong: a stolen session must not become a password oracle.
func (s *Server) reconfirm(r *http.Request, u *store.User, password, code string,
then func(st *store.State, u *store.User) error) error {
// The account as stored now, not as the session saw it at the start of
// the request.
s.st.Read(func(st *store.State) {
if cur := st.Users[u.ID]; cur != nil {
cp := *cur
u = &cp
}
})
if u.PassHash == "" {
return errNoPassword
}
if password == "" {
return errReauth
}
if u.TOTPConfirmed && strings.TrimSpace(code) == "" {
// Asked before the password is checked, so it says nothing about
// whether the password was right.
return errTOTPRequired
}
err := s.signIn(r, u.Username, password, code, then)
if errors.Is(err, errTOTPInvalid) || errors.Is(err, errTOTPRequired) {
return errBadCredentials
}
return err
}
// writeThrottled answers a refused attempt with 429 and Retry-After.
func writeThrottled(w http.ResponseWriter, err error) bool {
var t *errThrottled
if !errors.As(err, &t) {
return false
}
w.Header().Set("Retry-After", strconv.Itoa(int(t.RetryAfter.Seconds())+1))
fail(w, http.StatusTooManyRequests, "throttled", t.Error())
return true
}
// ---- limits ----
// limitWindow is how long failures are remembered.
const limitWindow = 15 * time.Minute
// Budgets per window. An address may fail more often than an account, since
// several people may share one address; an account fails rarely when its
// owner is the one typing.
var limitBudget = map[string]int{
"ip": 30,
"user": 10,
"start": 30, // unauthenticated device and single sign-on starts, per address
}
type attempts struct {
n int
since time.Time
}
// limiter counts failures per key ("ip:…", "user:…", "start:…").
type limiter struct {
mu sync.Mutex
m map[string]*attempts
now func() time.Time
}
func newLimiter() *limiter { return &limiter{m: map[string]*attempts{}, now: time.Now} }
func budget(key string) int {
kind, _, _ := strings.Cut(key, ":")
return limitBudget[kind]
}
// blocked says how long until every key is under budget again, or 0.
func (l *limiter) blocked(keys ...string) time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
var wait time.Duration
for _, k := range keys {
a := l.m[k]
if a == nil || now.Sub(a.since) >= limitWindow || a.n < budget(k) {
continue
}
if left := limitWindow - now.Sub(a.since); left > wait {
wait = left
}
}
return wait
}
// fail counts one failure against each key.
func (l *limiter) fail(keys ...string) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
if len(l.m) > 50_000 {
for k, a := range l.m {
if now.Sub(a.since) >= limitWindow {
delete(l.m, k)
}
}
}
for _, k := range keys {
a := l.m[k]
if a == nil || now.Sub(a.since) >= limitWindow {
a = &attempts{since: now}
l.m[k] = a
}
a.n++
}
}
// take counts one use of key and reports whether it was within budget, for
// endpoints limited by use rather than by failure.
func (l *limiter) take(key string) time.Duration {
if wait := l.blocked(key); wait > 0 {
return wait
}
l.fail(key)
return 0
}
func (l *limiter) reset(key string) {
l.mu.Lock()
delete(l.m, key)
l.mu.Unlock()
}
// ---- client addresses ----
// clientIP is the address a request came from. Behind a reverse proxy every
// request comes from the proxy, so for a peer listed in trusted_proxies the
// address is taken from X-Forwarded-For, right to left, skipping trusted
// hops; nobody else gets to choose their own address with a header.
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if !s.trusted(host) {
return host
}
hops := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(hops) - 1; i >= 0; i-- {
h := strings.TrimSpace(hops[i])
if h == "" {
continue
}
if !s.trusted(h) {
return h
}
host = h
}
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" && net.ParseIP(real) != nil {
return real
}
return host
}
func (s *Server) trusted(addr string) bool {
ip := net.ParseIP(addr)
if ip == nil {
return false
}
for _, n := range s.proxies {
if n.Contains(ip) {
return true
}
}
return false
}
// parseProxies reads trusted_proxies: addresses or CIDR ranges.
func parseProxies(list []string) ([]*net.IPNet, error) {
var out []*net.IPNet
for _, p := range list {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if !strings.Contains(p, "/") {
if ip := net.ParseIP(p); ip != nil && ip.To4() != nil {
p += "/32"
} else {
p += "/128"
}
}
_, n, err := net.ParseCIDR(p)
if err != nil {
return nil, fmt.Errorf("trusted_proxies: %q: %w", p, err)
}
out = append(out, n)
}
return out, nil
}
// maxLabel bounds a session or device label a client chooses: it is shown
// in lists and stored with every session, and nothing needs more.
const maxLabel = 64
// clampLabel trims a client-chosen label to something displayable.
func clampLabel(label string) string {
label = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, strings.TrimSpace(label))
if r := []rune(label); len(r) > maxLabel {
label = string(r[:maxLabel])
}
return label
}
+82
View File
@@ -0,0 +1,82 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestFailedSignInsAreThrottledPerAccount(t *testing.T) {
h := newHarness(t)
for i := 0; i < limitBudget["user"]; i++ {
if _, status, _ := h.login("admin", "wrong-password-x", ""); status != http.StatusUnauthorized {
t.Fatalf("attempt %d: %d", i, status)
}
}
// Over budget, even the right password is refused, before any hashing.
_, status, body := h.login("admin", adminPassword, "")
if status != http.StatusTooManyRequests || body["code"] != "throttled" {
t.Fatalf("after the budget: %d %v", status, body)
}
// Another account is not affected.
h.makeUserNoLogin("bob", "bob-password-1")
if _, status, _ := h.login("bob", "bob-password-1", ""); status != http.StatusOK {
t.Fatalf("another account was throttled too: %d", status)
}
// The window passes.
h.srv.limits.now = func() time.Time { return time.Now().Add(limitWindow) }
if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusOK {
t.Fatalf("still throttled after the window: %d", status)
}
}
func TestUnknownUsersCountAgainstTheAddress(t *testing.T) {
h := newHarness(t)
for i := 0; i < limitBudget["ip"]; i++ {
h.login("nobody-"+strings.Repeat("x", i%5+1), "whatever-password", "")
}
if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusTooManyRequests {
t.Fatalf("an address over its budget could still sign in: %d", status)
}
}
func TestClientIPTrustsOnlyConfiguredProxies(t *testing.T) {
proxies, err := parseProxies([]string{"10.0.0.0/8", "192.168.1.5"})
if err != nil {
t.Fatal(err)
}
s := &Server{proxies: proxies}
req := func(remote, xff string) *http.Request {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = remote
if xff != "" {
r.Header.Set("X-Forwarded-For", xff)
}
return r
}
for _, c := range []struct{ remote, xff, want string }{
{"203.0.113.9:1234", "1.2.3.4", "203.0.113.9"}, // untrusted peer: header ignored
{"10.1.2.3:1234", "198.51.100.7", "198.51.100.7"}, // proxy: its client
{"10.1.2.3:1234", "6.6.6.6, 198.51.100.7", "198.51.100.7"}, // a spoofed left entry is skipped
{"10.1.2.3:1234", "198.51.100.7, 192.168.1.5", "198.51.100.7"},
{"10.1.2.3:1234", "", "10.1.2.3"},
} {
if got := s.clientIP(req(c.remote, c.xff)); got != c.want {
t.Errorf("clientIP(%s, %q) = %s, want %s", c.remote, c.xff, got, c.want)
}
}
if _, err := parseProxies([]string{"not-an-address"}); err == nil {
t.Error("a malformed proxy was accepted")
}
}
func TestClampLabel(t *testing.T) {
if got := clampLabel(" phone\x1b[31m\n"); got != "phone[31m" {
t.Errorf("control characters kept: %q", got)
}
if got := clampLabel(strings.Repeat("ä", 200)); len([]rune(got)) != maxLabel {
t.Errorf("label not clamped: %d runes", len([]rune(got)))
}
}
+110
View File
@@ -0,0 +1,110 @@
package server
import (
"bytes"
"io"
"log"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"cc.tiredsysadmin.keeporg/internal/kdbx"
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
)
// A merge vault must never store a file the server could not afford to open
// later: its header is checked before anything is written.
func TestMergeVaultRefusesAnOverLimitKDF(t *testing.T) {
h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(tok, "Merged")
saved := kdbx.MaxArgon2Iterations
kdbx.MaxArgon2Iterations = 10_000
heavy, err := kdbx.New("Merged", kdbx.KDFParams{MemoryKiB: 8, Iterations: 5_000, Lanes: 1}).Marshal(vaultPassword)
kdbx.MaxArgon2Iterations = saved
if err != nil {
t.Fatal(err)
}
status, body, _ := h.push(tok, id, `"1"`, heavy)
if status != http.StatusBadRequest || body["code"] != "invalid_kdbx" {
t.Fatalf("over-limit push: %d %v", status, body)
}
if v := h.srv.blobs.Versions(id); len(v) != 1 {
t.Fatalf("versions after a refused push: %v", v)
}
}
// Two server processes on one database: a push accepted by a process whose
// view is stale must not overwrite the version another process committed.
func TestStalePushOnAnotherNodeDoesNotOverwrite(t *testing.T) {
url := sqldbtest.URL(t)
key := filepath.Join(t.TempDir(), "master.key")
h := newHarnessWith(t, func(c *Config) { c.Database, c.MasterKeyFile = url, key })
tok, _, _ := h.login("admin", adminPassword, "")
_, created := h.call("POST", "/api/v1/databases", tok, map[string]string{
"name": "Shared", "owner_group_id": "everyone", "merge_strategy": "server-wins"})
id := created["id"].(string)
if status, body, _ := h.push(tok, id, `"0"`, []byte("version one")); status != http.StatusOK {
t.Fatalf("v1: %d %v", status, body)
}
other, err := New(Config{DataDir: t.TempDir(), Database: url, MasterKeyFile: key,
ExternalURL: "http://other"}, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
defer other.Close()
ts := httptest.NewServer(other.Handler())
defer ts.Close()
put := func(base, data string) int {
req, _ := http.NewRequest("PUT", ts.URL+"/api/v1/databases/"+id+"/blob", bytes.NewReader([]byte(data)))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("If-Match", base)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
if status := put(`"1"`, "version two, by the other node"); status != http.StatusOK {
t.Fatalf("v2 on the other node: %d", status)
}
// The first node has not reloaded yet and still thinks v1 is current.
status, body, _ := h.push(tok, id, `"1"`, []byte("a stale edit"))
if status != http.StatusConflict {
t.Fatalf("stale push: %d %v", status, body)
}
got, err := h.srv.blobs.Get(id, 2)
if err != nil || string(got) != "version two, by the other node" {
t.Fatalf("version 2 is now %q (%v)", got, err)
}
}
func TestQueryTokenOnlyOpensTheEventStream(t *testing.T) {
h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "")
resp, err := http.Get(h.http.URL + "/api/v1/databases?access_token=" + tok)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("a token in the URL opened /databases: %d", resp.StatusCode)
}
}
func TestAdminEndpointsDoNotRevealDatabases(t *testing.T) {
h := newHarness(t)
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Private")
bob := h.makeUser("bob", "bob-password-1")
for _, target := range []string{id, "does-not-exist"} {
status, _ := h.call("PATCH", "/api/v1/databases/"+target, bob, map[string]string{"name": "x"})
if status != http.StatusNotFound {
t.Errorf("PATCH %s as an outsider: %d", target, status)
}
}
}
+12 -28
View File
@@ -2,24 +2,21 @@ package server
import ( import (
"net/http" "net/http"
"net/http/httptest"
"net/url" "net/url"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
) )
// blobsExist reports whether the server still keeps any version of a // blobsExist reports whether the server still keeps any version of a
// database on disk. // database.
func (h *harness) blobsExist(dbID string) bool { func (h *harness) blobsExist(dbID string) bool {
h.t.Helper() h.t.Helper()
_, err := os.Stat(filepath.Join(h.dataDir, "blobs", dbID)) return len(h.srv.blobs.Versions(dbID)) > 0
return err == nil
} }
func TestWebDeletesAVaultOnlyWithTheAdminsPassword(t *testing.T) { func TestWebDeletesAVaultOnlyWithTheAdminsPassword(t *testing.T) {
@@ -48,7 +45,7 @@ func TestWebDeletesAVaultOnlyWithTheAdminsPassword(t *testing.T) {
_, page = b.post("/web/vaults/"+id+"/delete", url.Values{ _, page = b.post("/web/vaults/"+id+"/delete", url.Values{
"csrf": {csrf}, "password": {"not-the-password"}}) "csrf": {csrf}, "password": {"not-the-password"}})
if !strings.Contains(page, "not your account password") { if !strings.Contains(page, "password or one-time code is wrong") {
t.Fatalf("a wrong password was not reported:\n%s", page) t.Fatalf("a wrong password was not reported:\n%s", page)
} }
h.srv.Store().Read(func(st *store.State) { h.srv.Store().Read(func(st *store.State) {
@@ -155,28 +152,15 @@ func TestDeleteAsksForTheSecondFactorToo(t *testing.T) {
admin, _, _ := h.login("admin", adminPassword, "") admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Guarded") id := h.makeVault(admin, "Guarded")
status, body := h.call("POST", "/api/v1/auth/totp/enroll", admin, nil) sec := h.enrollTOTP(t, admin, adminPassword)
if status != http.StatusOK {
t.Fatalf("enroll: %d %v", status, body)
}
sec := body["secret"].(string)
code, err := totp.Code(sec, time.Now())
if err != nil {
t.Fatal(err)
}
if status, body = h.call("POST", "/api/v1/auth/totp/confirm", admin,
map[string]string{"code": code}); status != http.StatusOK {
t.Fatalf("confirm: %d %v", status, body)
}
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin, if status, body := h.call("DELETE", "/api/v1/databases/"+id, admin,
map[string]string{"password": adminPassword}); status != http.StatusUnauthorized || map[string]string{"password": adminPassword}); status != http.StatusUnauthorized ||
body["code"] != "totp_required" { body["code"] != "totp_required" {
t.Fatalf("delete without a code: %d %v", status, body) t.Fatalf("delete without a code: %d %v", status, body)
} }
code, _ = totp.Code(sec, time.Now()) if status, body := h.call("DELETE", "/api/v1/databases/"+id, admin,
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin, map[string]string{"password": adminPassword, "totp": codeAt(t, sec, 0)}); status != http.StatusOK {
map[string]string{"password": adminPassword, "totp": code}); status != http.StatusOK {
t.Fatalf("delete with the code: %d %v", status, body) t.Fatalf("delete with the code: %d %v", status, body)
} }
} }
@@ -197,13 +181,13 @@ func TestSingleSignOnAccountConfirmsByName(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if _, err := h.srv.deleteDatabase(sso, id, "", "", ""); err != errConfirmName { if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", ""); err != errConfirmName {
t.Fatalf("delete with nothing typed = %v", err) t.Fatalf("delete with nothing typed = %v", err)
} }
if _, err := h.srv.deleteDatabase(sso, id, "", "", "federated"); err != errConfirmName { if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", "federated"); err != errConfirmName {
t.Fatalf("delete with the wrong case = %v", err) t.Fatalf("delete with the wrong case = %v", err)
} }
if _, err := h.srv.deleteDatabase(sso, id, "", "", "Federated"); err != nil { if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", "Federated"); err != nil {
t.Fatalf("delete with the name typed = %v", err) t.Fatalf("delete with the name typed = %v", err)
} }
h.srv.Store().Read(func(st *store.State) { h.srv.Store().Read(func(st *store.State) {
@@ -222,7 +206,7 @@ func TestDeleteNotifiesTheWatchers(t *testing.T) {
sub, events := h.srv.hub.Subscribe("admin") sub, events := h.srv.hub.Subscribe("admin")
defer h.srv.hub.Unsubscribe(sub) defer h.srv.hub.Unsubscribe(sub)
if _, err := h.srv.deleteDatabase(&store.User{ID: "admin", Username: "admin"}, if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), &store.User{ID: "admin", Username: "admin"},
id, adminPassword, "", ""); err != nil { id, adminPassword, "", ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+34 -17
View File
@@ -9,15 +9,14 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
"cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
) )
type harness struct { type harness struct {
@@ -43,6 +42,7 @@ func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
ts := httptest.NewUnstartedServer(nil) ts := httptest.NewUnstartedServer(nil)
cfg := Config{ cfg := Config{
DataDir: dir, DataDir: dir,
Database: sqldbtest.URL(t),
ExternalURL: "http://" + ts.Listener.Addr().String(), ExternalURL: "http://" + ts.Listener.Addr().String(),
MasterKeyFile: filepath.Join(t.TempDir(), "master.key"), MasterKeyFile: filepath.Join(t.TempDir(), "master.key"),
VaultKDF: testKDF, VaultKDF: testKDF,
@@ -54,6 +54,7 @@ func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(srv.Close)
hash, err := secret.Hash(adminPassword) hash, err := secret.Hash(adminPassword)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -73,14 +74,31 @@ func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
return &harness{t: t, srv: srv, http: ts, dataDir: dir} return &harness{t: t, srv: srv, http: ts, dataDir: dir}
} }
// stateBytes is the server's whole metadata document as stored on disk. // stateBytes is everything the server has stored, as the database holds it:
// every row of every table, concatenated. A test that something is not kept
// in the clear searches this.
func (h *harness) stateBytes() []byte { func (h *harness) stateBytes() []byte {
h.t.Helper() h.t.Helper()
b, err := os.ReadFile(filepath.Join(h.dataDir, "state.json")) var out []byte
for _, q := range []string{
`SELECT name, value FROM keeporg_meta`,
`SELECT id, body FROM keeporg_records`,
`SELECT ref, body FROM keeporg_blobs`,
} {
rows, err := h.srv.db.Query(q)
if err != nil { if err != nil {
h.t.Fatal(err) h.t.Fatal(err)
} }
return b for rows.Next() {
var a, b []byte
if err := rows.Scan(&a, &b); err != nil {
h.t.Fatal(err)
}
out = append(append(out, a...), b...)
}
rows.Close()
}
return out
} }
// call sends a JSON request and decodes the JSON response. // call sends a JSON request and decodes the JSON response.
@@ -357,30 +375,28 @@ func TestEventStreamDeliversPush(t *testing.T) {
func TestTOTPEnrollmentThenRequiredAtLogin(t *testing.T) { func TestTOTPEnrollmentThenRequiredAtLogin(t *testing.T) {
h := newHarness(t) h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "") tok, _, _ := h.login("admin", adminPassword, "")
status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil) status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, map[string]string{"password": adminPassword})
if status != http.StatusOK { if status != http.StatusOK || !strings.HasPrefix(body["uri"].(string), "otpauth://totp/") {
t.Fatalf("enroll: %d %v", status, body) t.Fatalf("enroll: %d %v", status, body)
} }
sec := body["secret"].(string) sec := body["secret"].(string)
if !strings.HasPrefix(body["uri"].(string), "otpauth://totp/") {
t.Fatalf("uri = %v", body["uri"])
}
code, err := totp.Code(sec, time.Now())
if err != nil {
t.Fatal(err)
}
if status, body = h.call("POST", "/api/v1/auth/totp/confirm", tok, if status, body = h.call("POST", "/api/v1/auth/totp/confirm", tok,
map[string]string{"code": code}); status != http.StatusOK { map[string]string{"code": codeAt(t, sec, -1)}); status != http.StatusOK {
t.Fatalf("confirm: %d %v", status, body) t.Fatalf("confirm: %d %v", status, body)
} }
if _, status, body = h.login("admin", adminPassword, ""); status != http.StatusUnauthorized || if _, status, body = h.login("admin", adminPassword, ""); status != http.StatusUnauthorized ||
body["code"] != "totp_required" { body["code"] != "totp_required" {
t.Fatalf("login without code: %d %v", status, body) t.Fatalf("login without code: %d %v", status, body)
} }
code, _ = totp.Code(sec, time.Now()) code := codeAt(t, sec, 0)
if _, status, body = h.login("admin", adminPassword, code); status != http.StatusOK { if _, status, body = h.login("admin", adminPassword, code); status != http.StatusOK {
t.Fatalf("login with code: %d %v", status, body) t.Fatalf("login with code: %d %v", status, body)
} }
// The same code a second time is refused: each code is good once.
if _, status, body = h.login("admin", adminPassword, code); status != http.StatusUnauthorized ||
body["code"] != "totp_invalid" {
t.Fatalf("replayed code: %d %v", status, body)
}
} }
func TestDeviceLoginApprovedInBrowser(t *testing.T) { func TestDeviceLoginApprovedInBrowser(t *testing.T) {
@@ -441,7 +457,8 @@ func TestNonAdminCannotAdminister(t *testing.T) {
t.Fatalf("outsider created a database: %d", status) t.Fatalf("outsider created a database: %d", status)
} }
if status, _ := h.call("PATCH", "/api/v1/databases/"+db, otok, if status, _ := h.call("PATCH", "/api/v1/databases/"+db, otok,
map[string]string{"merge_strategy": "client-wins"}); status != http.StatusForbidden { map[string]string{"merge_strategy": "client-wins"}); status != http.StatusNotFound {
// 404, not 403: without access the database does not exist for them.
t.Fatalf("outsider changed the strategy: %d", status) t.Fatalf("outsider changed the strategy: %d", status)
} }
// Without any grant the database is invisible, not merely unwritable. // Without any grant the database is invisible, not merely unwritable.
+27 -7
View File
@@ -1,6 +1,8 @@
package server package server
import ( import (
"context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -18,7 +20,21 @@ import (
// //
// Any failure answers with a conflict instead, so the client falls back to // Any failure answers with a conflict instead, so the client falls back to
// keeping its copy aside rather than losing an edit. // keeping its copy aside rather than losing an edit.
func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, basedOn int64, incoming []byte) { func (s *Server) mergePush(w http.ResponseWriter, r *http.Request, u *store.User, id string, basedOn int64, incoming []byte) {
// A merge runs the vault's key derivation three times and holds two
// decrypted copies; only a few run at once, and a push that cannot get
// a turn soon is told to retry rather than piling up.
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
select {
case s.merges <- struct{}{}:
defer func() { <-s.merges }()
case <-ctx.Done():
w.Header().Set("Retry-After", "5")
fail(w, http.StatusServiceUnavailable, "busy", "the server is busy merging; try again shortly")
return
}
var ( var (
wrapped string wrapped string
current int64 current int64
@@ -36,7 +52,7 @@ func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, base
w.Header().Set("ETag", etag(current)) w.Header().Set("ETag", etag(current))
w.Header().Set("X-KeepOrg-Strategy", string(store.StrategyMerge)) w.Header().Set("X-KeepOrg-Strategy", string(store.StrategyMerge))
writeJSON(w, http.StatusConflict, map[string]any{ writeJSON(w, http.StatusConflict, map[string]any{
"code": "merge_failed", "error": err.Error(), "code": "merge_failed", "error": "the server could not merge this save; the server log says why",
"current_version": current, "merge_strategy": store.StrategyMerge, "current_version": current, "merge_strategy": store.StrategyMerge,
}) })
return return
@@ -48,7 +64,8 @@ func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, base
watchers []string watchers []string
stale bool stale bool
) )
if err := s.st.Update(func(st *store.State) error { if err := s.st.UpdateWith(func(st *store.State) error {
stale = false
d := st.Databases[id] d := st.Databases[id]
if d == nil { if d == nil {
return errors.New("no such database") return errors.New("no such database")
@@ -59,15 +76,18 @@ func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, base
return nil return nil
} }
next = d.Version + 1 next = d.Version + 1
if err := s.blobs.Put(id, next, merged); err != nil {
return err
}
d.Version, d.SHA256, d.Size = next, sum, int64(len(merged)) d.Version, d.SHA256, d.Size = next, sum, int64(len(merged))
d.UpdatedAt, d.UpdatedBy = time.Now(), u.Username d.UpdatedAt, d.UpdatedBy = time.Now(), u.Username
watchers = st.Watchers(id) watchers = st.Watchers(id)
return nil return nil
}, func(ctx context.Context, tx *sql.Tx) error {
if stale {
return nil
}
return s.blobs.PutTx(ctx, tx, id, next, merged)
}); err != nil { }); err != nil {
fail(w, http.StatusInternalServerError, "internal", err.Error()) s.log.Printf("merge %s: %v", id, err)
fail(w, http.StatusInternalServerError, "internal", "the merge could not be stored; try again")
return return
} }
if stale { if stale {
+198 -33
View File
@@ -1,6 +1,8 @@
package server package server
import ( import (
"crypto/sha256"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -28,6 +30,12 @@ type OIDCConfig struct {
AutoCreate bool `json:"auto_create"` AutoCreate bool `json:"auto_create"`
// DefaultGroupID is the group auto-created users join. // DefaultGroupID is the group auto-created users join.
DefaultGroupID string `json:"default_group_id"` DefaultGroupID string `json:"default_group_id"`
// LinkVerifiedEmail lets an identity claim an existing, unlinked local
// account whose email it shares, when the provider says the email is
// verified. Off by default: whoever controls an identity provider's
// email field then controls those accounts. Accounts can always be
// linked explicitly from their Account page.
LinkVerifiedEmail bool `json:"link_verified_email"`
} }
func (c OIDCConfig) Enabled() bool { func (c OIDCConfig) Enabled() bool {
@@ -87,12 +95,22 @@ func (p *oidcProvider) metadata() (*oidcMetadata, error) {
type oidcIdentity struct { type oidcIdentity struct {
Subject string `json:"sub"` Subject string `json:"sub"`
Email string `json:"email"` Email string `json:"email"`
EmailVerified flexBool `json:"email_verified"`
Username string `json:"preferred_username"` Username string `json:"preferred_username"`
Name string `json:"name"` Name string `json:"name"`
} }
// flexBool reads a JSON boolean that some providers send as a string.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
v := strings.Trim(string(data), `"`)
*b = flexBool(v == "true")
return nil
}
// exchange turns an authorization code into the caller's identity. // exchange turns an authorization code into the caller's identity.
func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) { func (p *oidcProvider) exchange(code, verifier string) (*oidcIdentity, error) {
meta, err := p.metadata() meta, err := p.metadata()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -101,6 +119,8 @@ func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) {
"grant_type": {"authorization_code"}, "grant_type": {"authorization_code"},
"code": {code}, "code": {code},
"redirect_uri": {p.redirectURI}, "redirect_uri": {p.redirectURI},
// PKCE: only the party that started the flow can spend the code.
"code_verifier": {verifier},
} }
req, err := http.NewRequest(http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode())) req, err := http.NewRequest(http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil { if err != nil {
@@ -153,59 +173,114 @@ func (p *oidcProvider) userinfo(endpoint, accessToken string) (*oidcIdentity, er
return &id, nil return &id, nil
} }
// handleOIDCLogin starts the authorization code flow for a pending device. // oidcCookie binds a single sign-on round trip to the browser that started
// it. The state in the URL alone is not enough: a callback link forwarded to
// someone else, or a flow started by an attacker's page in a victim's
// browser, would otherwise finish in the wrong hands. Lax, because the
// provider's redirect back is a cross-site navigation.
const oidcCookie = "keeporg_oidc"
func (s *Server) setOIDCCookie(w http.ResponseWriter, value string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: oidcCookie, Value: value, Path: "/oidc", HttpOnly: true,
Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"),
SameSite: http.SameSiteLaxMode, MaxAge: int(ttl.Seconds()),
})
}
func oidcCookieMatches(r *http.Request, want string) bool {
c, err := r.Cookie(oidcCookie)
return err == nil && want != "" && secret.Equal(c.Value, want)
}
// pkceChallenge is the S256 challenge for a verifier.
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// handleOIDCLogin starts the authorization code flow. It has three uses:
// with ?code= it identifies the person approving a waiting client; with
// ?link=1 it links an identity to the account signed in here; without
// either it signs a browser in to the web interface.
func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
if s.oidc == nil { if s.oidc == nil {
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.") s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
return return
} }
if wait := s.limits.take("start:" + s.clientIP(r)); wait > 0 {
s.renderDone(w, "Too many sign-in attempts", (&errThrottled{RetryAfter: wait}).Error())
return
}
meta, err := s.oidc.metadata() meta, err := s.oidc.metadata()
if err != nil { if err != nil {
s.log.Printf("oidc discovery: %v", err) s.log.Printf("oidc discovery: %v", err)
s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.") s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.")
return return
} }
// With a user code this approves a waiting client; without one it signs q := r.URL.Query()
// a browser in to the web interface. userCode := strings.ToUpper(strings.TrimSpace(q.Get("code")))
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code"))) var linkUser string
state := secret.Token() if q.Get("link") == "1" {
// Linking changes how an account signs in, so it needs that
// account's session and its CSRF token: a page elsewhere must not
// be able to link the attacker's identity to the victim's account.
sess := s.webUser(r)
if sess == nil || !secret.Equal(sess.csrf, q.Get("csrf")) {
s.renderDone(w, "Single sign-on not linked", "Start again from your Account page.")
return
}
linkUser = sess.user.ID
}
state, verifier := secret.Token(), secret.Token()
if err := s.st.Update(func(st *store.State) error { if err := s.st.Update(func(st *store.State) error {
if userCode == "" { if userCode == "" {
if !pruneDevices(st) {
return errBusy
}
st.Devices[state] = &store.DeviceAuth{ st.Devices[state] = &store.DeviceAuth{
DeviceCode: state, Web: true, Label: "web", OIDCState: state, DeviceCode: state, Web: linkUser == "", LinkUserID: linkUser, Label: "web",
OIDCState: state, PKCEVerifier: verifier,
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL), CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL),
} }
return nil return nil
} }
d := st.DeviceByUserCode(userCode) d := st.DeviceByUserCode(userCode)
if d == nil || time.Now().After(d.ExpiresAt) { if d == nil || time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
return errors.New("unknown or expired device code") return errors.New("unknown or expired device code")
} }
d.OIDCState = state d.OIDCState, d.PKCEVerifier = state, verifier
return nil return nil
}); err != nil { }); err != nil {
s.renderDevice(w, userCode, err.Error()) s.renderDevice(w, userCode, err.Error())
return return
} }
q := url.Values{ s.setOIDCCookie(w, state, deviceTTL)
v := url.Values{
"response_type": {"code"}, "response_type": {"code"},
"client_id": {s.oidc.cfg.ClientID}, "client_id": {s.oidc.cfg.ClientID},
"redirect_uri": {s.oidc.redirectURI}, "redirect_uri": {s.oidc.redirectURI},
"scope": {s.oidc.cfg.Scopes}, "scope": {s.oidc.cfg.Scopes},
"state": {state}, "state": {state},
"code_challenge": {pkceChallenge(verifier)},
"code_challenge_method": {"S256"},
} }
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound) http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+v.Encode(), http.StatusFound)
} }
// handleOIDCCallback finishes the flow and approves the waiting device. // handleOIDCCallback finishes the flow. A browser sign-in ends signed in; a
// link ends linked; a waiting client is not approved here but shown to the
// person for an explicit decision, because arriving at this URL proves only
// that a browser with a provider session was sent here, not that its owner
// meant to let a device in.
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
if s.oidc == nil { if s.oidc == nil {
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.") s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
return return
} }
q := r.URL.Query() q := r.URL.Query()
if e := q.Get("error"); e != "" { if q.Get("error") != "" {
s.renderDone(w, "Sign-in failed", e) s.renderDone(w, "Sign-in failed", "The identity provider refused the sign-in.")
return return
} }
state, code := q.Get("state"), q.Get("code") state, code := q.Get("state"), q.Get("code")
@@ -213,26 +288,34 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
s.renderDone(w, "Sign-in failed", "The provider did not return a code.") s.renderDone(w, "Sign-in failed", "The provider did not return a code.")
return return
} }
// Bind the state to a pending device before spending the code. if !oidcCookieMatches(r, state) {
var found bool s.renderDone(w, "Sign-in failed",
"This sign-in was not started in this browser. Start again from the same browser.")
return
}
s.setOIDCCookie(w, "", -time.Second)
var verifier string
s.st.Read(func(st *store.State) { s.st.Read(func(st *store.State) {
for _, d := range st.Devices { for _, d := range st.Devices {
if d.OIDCState != "" && secret.Equal(d.OIDCState, state) && time.Now().Before(d.ExpiresAt) { if d.OIDCState != "" && secret.Equal(d.OIDCState, state) && time.Now().Before(d.ExpiresAt) {
found = true verifier = d.PKCEVerifier
} }
} }
}) })
if !found { if verifier == "" {
s.renderDone(w, "Sign-in failed", "This sign-in request has expired. Start again from the client.") s.renderDone(w, "Sign-in failed", "This sign-in request has expired. Start again from the client.")
return return
} }
id, err := s.oidc.exchange(code) id, err := s.oidc.exchange(code, verifier)
if err != nil { if err != nil {
s.log.Printf("oidc exchange: %v", err) s.log.Printf("oidc exchange: %v", err)
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.") s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
return return
} }
var webToken, returnURL string var (
webToken, linked string
confirm *webDeviceConfirm
)
if err := s.st.Update(func(st *store.State) error { if err := s.st.Update(func(st *store.State) error {
var dev *store.DeviceAuth var dev *store.DeviceAuth
for _, d := range st.Devices { for _, d := range st.Devices {
@@ -243,33 +326,111 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
if dev == nil || time.Now().After(dev.ExpiresAt) { if dev == nil || time.Now().After(dev.ExpiresAt) {
return errors.New("this sign-in request has expired") return errors.New("this sign-in request has expired")
} }
dev.OIDCState, dev.PKCEVerifier = "", ""
if dev.LinkUserID != "" {
delete(st.Devices, dev.DeviceCode)
return s.linkIdentity(st, dev.LinkUserID, id, &linked)
}
u, err := s.linkOIDCUser(st, id) u, err := s.linkOIDCUser(st, id)
if err != nil { if err != nil {
return err return err
} }
dev.OIDCState = ""
if dev.Web { if dev.Web {
token := s.newBrowserSession(st, u.ID, r).Token
// Nothing is waiting to poll for this one; the browser in front // Nothing is waiting to poll for this one; the browser in front
// of us is the client. // of us is the client.
webToken = token webToken = s.newBrowserSession(st, u.ID, r).Token
delete(st.Devices, dev.DeviceCode) delete(st.Devices, dev.DeviceCode)
return nil return nil
} }
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token dev.PendingUserID, dev.ApprovalNonce = u.ID, secret.Token()
returnURL = dev.ReturnURL confirm = &webDeviceConfirm{Nonce: dev.ApprovalNonce, User: u.Username, Label: dev.Label,
Code: dev.UserCode, Started: ago(dev.CreatedAt)}
return nil return nil
}); err != nil { }); err != nil {
s.renderDone(w, "Sign-in failed", err.Error()) s.renderDone(w, "Sign-in failed", humanError(err))
return return
} }
if webToken != "" { switch {
case webToken != "":
s.setWebCookie(w, webToken, s.cfg.SessionTTL) s.setWebCookie(w, webToken, s.cfg.SessionTTL)
http.Redirect(w, r, "/web/vaults", http.StatusFound) http.Redirect(w, r, "/web/vaults", http.StatusFound)
case linked != "":
http.Redirect(w, r, "/web/account", http.StatusFound)
default:
s.setOIDCCookie(w, confirm.Nonce, deviceTTL)
render(w, deviceConfirmTmpl, map[string]any{"Title": "Approve a device", "D": confirm})
}
}
// webDeviceConfirm is the question put to someone who signed in through
// their provider to approve a device.
type webDeviceConfirm struct {
Nonce, User, Label, Code, Started string
}
// handleOIDCApprove is the person's answer. It needs the nonce from the
// confirmation page and the same nonce in this browser's cookie, so it can
// only come from the page the callback showed in this browser.
func (s *Server) handleOIDCApprove(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
fail(w, http.StatusBadRequest, "bad_request", err.Error())
return return
} }
nonce := r.FormValue("nonce")
if !oidcCookieMatches(r, nonce) {
s.renderDone(w, "Not approved", "This approval was not started in this browser.")
return
}
s.setOIDCCookie(w, "", -time.Second)
approve := r.FormValue("decision") == "approve"
var returnURL string
err := s.st.Update(func(st *store.State) error {
for code, d := range st.Devices {
if d.ApprovalNonce == "" || !secret.Equal(d.ApprovalNonce, nonce) {
continue
}
if time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
return errors.New("this sign-in request has expired")
}
if !approve {
delete(st.Devices, code)
return nil
}
u := st.Users[d.PendingUserID]
if u == nil || u.Disabled {
return errors.New("this account cannot sign in")
}
d.ApprovalNonce = ""
d.SessionToken = s.newSession(st, u.ID, d.Label).Token
returnURL = d.ReturnURL
return nil
}
return errors.New("this sign-in request has expired")
})
switch {
case err != nil:
s.renderDone(w, "Not approved", humanError(err))
case !approve:
s.renderDone(w, "Device refused", "The device was not signed in. If you did not start this, someone tried to.")
default:
s.renderApproved(w, returnURL) s.renderApproved(w, returnURL)
} }
}
// linkIdentity links an identity to an account its owner is signed in to.
func (s *Server) linkIdentity(st *store.State, userID string, id *oidcIdentity, linked *string) error {
u := st.Users[userID]
if u == nil || u.Disabled {
return errors.New("this account cannot be linked")
}
issuer := strings.TrimRight(s.oidc.cfg.Issuer, "/")
if other := st.UserByOIDC(issuer, id.Subject); other != nil && other.ID != u.ID {
return errors.New("this identity is already linked to another account")
}
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
*linked = u.Username
return nil
}
// linkOIDCUser finds the account behind an identity, provisioning one when // linkOIDCUser finds the account behind an identity, provisioning one when
// the deployment allows it. // the deployment allows it.
@@ -281,20 +442,24 @@ func (s *Server) linkOIDCUser(st *store.State, id *oidcIdentity) (*store.User, e
} }
return u, nil return u, nil
} }
// A local account may claim the identity by matching username or email, // Never by username: anyone who can choose their preferred_username at
// which is how an invited user upgrades to single sign-on. // the provider could otherwise claim "admin". By email only when the
// deployment allows it and the provider vouches for the address, and
// never for a server administrator, whose account is linked from its
// own Account page or not at all.
if s.oidc.cfg.LinkVerifiedEmail && bool(id.EmailVerified) && id.Email != "" {
for _, u := range st.Users { for _, u := range st.Users {
if u.OIDCSubject != "" { if u.OIDCSubject != "" || u.ServerAdmin || u.Disabled {
continue continue
} }
if (id.Username != "" && u.Username == id.Username) || if strings.EqualFold(u.Email, id.Email) {
(id.Email != "" && strings.EqualFold(u.Email, id.Email)) {
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
return u, nil return u, nil
} }
} }
}
if !s.oidc.cfg.AutoCreate { if !s.oidc.cfg.AutoCreate {
return nil, errors.New("no account is linked to this identity; redeem an invite link first") return nil, errors.New("no account is linked to this identity; sign in with your password and link single sign-on from your Account page")
} }
name := id.Username name := id.Username
if name == "" { if name == "" {
+149 -25
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"regexp"
"strings" "strings"
"testing" "testing"
@@ -17,9 +18,12 @@ import (
type fakeIdP struct { type fakeIdP struct {
*httptest.Server *httptest.Server
subject, email, username string subject, email, username string
emailVerified bool
// tokenAuth records the client credentials the server presented. // tokenAuth records the client credentials the server presented.
tokenAuth [2]string tokenAuth [2]string
authCalls int authCalls int
// challenge is the PKCE challenge of the last authorization request.
challenge string
} }
func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP { func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
@@ -39,6 +43,11 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
mux.HandleFunc("GET /authorize", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /authorize", func(w http.ResponseWriter, r *http.Request) {
idp.authCalls++ idp.authCalls++
q := r.URL.Query() q := r.URL.Query()
if q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" {
http.Error(w, "PKCE required", http.StatusBadRequest)
return
}
idp.challenge = q.Get("code_challenge")
back, err := url.Parse(q.Get("redirect_uri")) back, err := url.Parse(q.Get("redirect_uri"))
if err != nil { if err != nil {
http.Error(w, "bad redirect_uri", http.StatusBadRequest) http.Error(w, "bad redirect_uri", http.StatusBadRequest)
@@ -58,6 +67,10 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
http.Error(w, "bad code", http.StatusBadRequest) http.Error(w, "bad code", http.StatusBadRequest)
return return
} }
if pkceChallenge(r.FormValue("code_verifier")) != idp.challenge {
http.Error(w, "PKCE verifier does not match", http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "an-access-token", "token_type": "Bearer", "expires_in": 300, "access_token": "an-access-token", "token_type": "Bearer", "expires_in": 300,
}) })
@@ -67,8 +80,9 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
_ = json.NewEncoder(w).Encode(map[string]string{ _ = json.NewEncoder(w).Encode(map[string]any{
"sub": idp.subject, "email": idp.email, "preferred_username": idp.username, "sub": idp.subject, "email": idp.email, "preferred_username": idp.username,
"email_verified": idp.emailVerified,
}) })
}) })
return idp return idp
@@ -136,54 +150,164 @@ func TestOIDCSignsInToTheWebInterface(t *testing.T) {
} }
} }
func TestOIDCApprovesAWaitingClient(t *testing.T) { var noncePattern = regexp.MustCompile(`name="nonce" value="([^"]+)"`)
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
// startDeviceApproval starts a device login and takes a browser through
// single sign-on for it, up to the confirmation page.
func startDeviceApproval(t *testing.T, h *harness, b *browser) (start map[string]any, nonce string) {
t.Helper()
status, start := h.call("POST", "/api/v1/auth/device/start", "", status, start := h.call("POST", "/api/v1/auth/device/start", "",
map[string]string{"label": "phone", "return_url": "keeporg://device-approved"}) map[string]string{"label": "phone", "return_url": "keeporg://device-approved"})
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("device start: %d %v", status, start) t.Fatalf("device start: %d %v", status, start)
} }
userCode := start["user_code"].(string) status, page := b.get("/oidc/login?code=" + url.QueryEscape(start["user_code"].(string)))
if status != http.StatusOK || !strings.Contains(page, "Let a device in?") ||
b := newBrowser(t, h) !strings.Contains(page, start["user_code"].(string)) {
status, page := b.get("/oidc/login?code=" + url.QueryEscape(userCode)) t.Fatalf("no confirmation page after single sign-on: %d\n%s", status, page)
if status != http.StatusOK || !strings.Contains(page, "Device approved") {
t.Fatalf("device approval through single sign-on: %d\n%s", status, page)
} }
// A phone is handed back to its app from the provider's redirect too. m := noncePattern.FindStringSubmatch(page)
if !strings.Contains(page, `href="keeporg://device-approved"`) { if m == nil {
t.Fatalf("no link back to the app after single sign-on:\n%s", page) t.Fatalf("no nonce on the confirmation page:\n%s", page)
}
return start, m[1]
}
func TestOIDCApprovesAWaitingClient(t *testing.T) {
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
b := newBrowser(t, h)
start, nonce := startDeviceApproval(t, h, b)
poll := func() (int, map[string]any) {
return h.call("POST", "/api/v1/auth/device/poll", "",
map[string]string{"device_code": start["device_code"].(string)})
}
// Signing in at the provider is not yet an approval: one click on a
// link must never let somebody else's device in.
if status, _ := poll(); status != http.StatusAccepted {
t.Fatalf("the device was approved before the person said yes: %d", status)
}
_, page := b.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"approve"}})
if !strings.Contains(page, "Device approved") || !strings.Contains(page, `href="keeporg://device-approved"`) {
t.Fatalf("approval page:\n%s", page)
} }
// A device approval must not also hand the browser a session cookie. // A device approval must not also hand the browser a session cookie.
if b.cookie() != "" { if b.cookie() != "" {
t.Fatal("approving a device set a web session cookie") t.Fatal("approving a device set a web session cookie")
} }
status, body := poll()
status, body := h.call("POST", "/api/v1/auth/device/poll", "",
map[string]string{"device_code": start["device_code"].(string)})
if status != http.StatusOK || body["token"] == "" { if status != http.StatusOK || body["token"] == "" {
t.Fatalf("poll after single sign-on: %d %v", status, body) t.Fatalf("poll after approval: %d %v", status, body)
} }
if u := body["user"].(map[string]any); u["username"] != "devuser" { if u := body["user"].(map[string]any); u["username"] != "devuser" {
t.Fatalf("the session belongs to %v", u["username"]) t.Fatalf("the session belongs to %v", u["username"])
} }
} }
func TestOIDCLinksAnExistingAccountByUsername(t *testing.T) { func TestOIDCDeviceApprovalCanBeRefused(t *testing.T) {
idp := newFakeIdP(t, "subject-6", "", "devuser")
h := newHarnessWith(t, idp.withOIDC(true, ""))
b := newBrowser(t, h)
start, nonce := startDeviceApproval(t, h, b)
_, page := b.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"deny"}})
if !strings.Contains(page, "not signed in") {
t.Fatalf("refusal page:\n%s", page)
}
if status, _ := h.call("POST", "/api/v1/auth/device/poll", "",
map[string]string{"device_code": start["device_code"].(string)}); status != http.StatusGone {
t.Fatalf("a refused device can still poll: %d", status)
}
}
// The attack: someone starts a device login and gets the victim's browser to
// the provider with that device's code. The victim's browser ends on the
// confirmation page; the approval itself cannot be posted from anywhere
// else, because it needs this browser's cookie.
func TestOIDCApprovalCannotBePostedFromAnotherBrowser(t *testing.T) {
idp := newFakeIdP(t, "subject-7", "", "devuser")
h := newHarnessWith(t, idp.withOIDC(true, ""))
victim := newBrowser(t, h)
start, nonce := startDeviceApproval(t, h, victim)
attacker := newBrowser(t, h)
_, page := attacker.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"approve"}})
if !strings.Contains(page, "not started in this browser") {
t.Fatalf("an approval was accepted from another browser:\n%s", page)
}
if status, _ := h.call("POST", "/api/v1/auth/device/poll", "",
map[string]string{"device_code": start["device_code"].(string)}); status != http.StatusAccepted {
t.Fatalf("the device got in: %d", status)
}
}
func TestOIDCNeverLinksByUsername(t *testing.T) {
idp := newFakeIdP(t, "subject-3", "", "admin") idp := newFakeIdP(t, "subject-3", "", "admin")
h := newHarnessWith(t, idp.withOIDC(true, ""))
b := newBrowser(t, h)
b.get("/oidc/login")
h.srv.Store().Read(func(st *store.State) {
if st.Users["admin"].OIDCSubject != "" {
t.Fatal("an identity calling itself admin claimed the admin account")
}
if u := st.UserByOIDC(idp.URL, "subject-3"); u == nil || u.Username == "admin" || u.ServerAdmin {
t.Fatalf("the identity got %+v", u)
}
})
}
func TestOIDCLinksAVerifiedEmailOnlyWhenAllowed(t *testing.T) {
for _, c := range []struct {
name string
allow, verified bool
wantLinkedToBob bool
}{
{"off", false, true, false},
{"unverified", true, false, false},
{"verified", true, true, true},
} {
t.Run(c.name, func(t *testing.T) {
idp := newFakeIdP(t, "subject-8", "bob@example.com", "someone-else")
idp.emailVerified = c.verified
h := newHarnessWith(t, func(cfg *Config) {
idp.withOIDC(false, "")(cfg)
cfg.OIDC.LinkVerifiedEmail = c.allow
})
_ = h.srv.Store().Update(func(st *store.State) error {
st.Users["bob"] = &store.User{ID: "bob", Username: "bob", Email: "bob@example.com", PassHash: "x"}
return nil
})
newBrowser(t, h).get("/oidc/login")
var linked bool
h.srv.Store().Read(func(st *store.State) { linked = st.Users["bob"].OIDCSubject == "subject-8" })
if linked != c.wantLinkedToBob {
t.Fatalf("linked = %v, want %v", linked, c.wantLinkedToBob)
}
})
}
}
func TestOIDCIsLinkedFromTheAccountPage(t *testing.T) {
idp := newFakeIdP(t, "subject-9", "", "anything")
h := newHarnessWith(t, idp.withOIDC(false, "")) h := newHarnessWith(t, idp.withOIDC(false, ""))
b := newBrowser(t, h) b := newBrowser(t, h)
csrf := b.signIn()
if _, page := b.get("/oidc/login"); !strings.Contains(page, "Your vaults") { // Without the session's CSRF token, a link elsewhere cannot do it.
t.Fatalf("an existing account was not matched:\n%s", page) b.get("/oidc/login?link=1")
_, page := b.get("/web/account")
if !strings.Contains(page, "Link single sign-on") {
t.Fatalf("the account page does not offer linking:\n%s", page)
} }
var u *store.User _, page = b.get("/oidc/login?link=1&csrf=" + url.QueryEscape(csrf))
h.srv.Store().Read(func(st *store.State) { u = st.Users["admin"] }) if !strings.Contains(page, "single sign-on") || strings.Contains(page, "Link single sign-on") {
if u.OIDCSubject != "subject-3" { t.Fatalf("not linked:\n%s", page)
t.Fatalf("the existing account was not linked: %+v", u)
} }
h.srv.Store().Read(func(st *store.State) {
if st.Users["admin"].OIDCSubject != "subject-9" {
t.Fatalf("admin = %+v", st.Users["admin"])
}
})
} }
func TestOIDCRefusesAnUnknownIdentityWhenProvisioningIsOff(t *testing.T) { func TestOIDCRefusesAnUnknownIdentityWhenProvisioningIsOff(t *testing.T) {
@@ -206,7 +330,7 @@ func TestOIDCCallbackRejectsAnUnknownState(t *testing.T) {
b := newBrowser(t, h) b := newBrowser(t, h)
_, page := b.get("/oidc/callback?code=authorization-code&state=not-a-real-state") _, page := b.get("/oidc/callback?code=authorization-code&state=not-a-real-state")
if !strings.Contains(page, "expired") { if !strings.Contains(page, "not started in this browser") {
t.Fatalf("a forged callback was accepted:\n%s", page) t.Fatalf("a forged callback was accepted:\n%s", page)
} }
if b.cookie() != "" { if b.cookie() != "" {
+20 -1
View File
@@ -121,6 +121,7 @@ var signInTmpl = template.Must(template.Must(pages.Clone()).Parse(`
<p class="sub">Sign in to create and manage your vaults.</p> <p class="sub">Sign in to create and manage your vaults.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}} {{if .Error}}<p class="err">{{.Error}}</p>{{end}}
<form method="post" action="/web/signin"> <form method="post" action="/web/signin">
<input type="hidden" name="pre" value="{{.Pre}}">
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus> <label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus>
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required> <label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
<label for="totp">One-time code <small>(if enabled)</small></label><input id="totp" name="totp" inputmode="numeric" autocomplete="one-time-code"> <label for="totp">One-time code <small>(if enabled)</small></label><input id="totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
@@ -236,6 +237,23 @@ to ask for.</p>
{{end}} {{end}}
{{end}}`)) {{end}}`))
var deviceConfirmTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}{{with .D}}<h1>Let a device in?</h1>
<p class="sub">A KeepOrg client asked to sign in as <b>{{.User}}</b>.</p>
<table><tbody>
<tr><th>Device</th><td class="name">{{if .Label}}{{.Label}}{{else}}unnamed{{end}}</td></tr>
<tr><th>Its code</th><td><code>{{.Code}}</code></td></tr>
<tr><th>Started</th><td>{{.Started}}</td></tr>
</tbody></table>
<p class="note">Approve only if you started this sign-in yourself, just now,
and your device shows the code above. Anyone who sent you a link to this page
gets your access if you approve.</p>
<form method="post" action="/oidc/approve">
<input type="hidden" name="nonce" value="{{.Nonce}}">
<button type="submit" name="decision" value="approve">Approve {{if .Label}}{{.Label}}{{else}}this device{{end}}</button>
<button class="secondary" type="submit" name="decision" value="deny">This was not me</button>
</form>{{end}}{{end}}`))
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(` var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p> {{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>
{{if .Return}}<p><a class="button" href="{{.Return}}">Return to KeepOrg</a></p>{{end}}{{end}}`)) {{if .Return}}<p><a class="button" href="{{.Return}}">Return to KeepOrg</a></p>{{end}}{{end}}`))
@@ -250,9 +268,10 @@ func (s *Server) renderInvite(w http.ResponseWriter, token, errMsg string) {
render(w, inviteTmpl, map[string]any{"Title": "Create account", "Token": token, "Error": errMsg}) render(w, inviteTmpl, map[string]any{"Title": "Create account", "Token": token, "Error": errMsg})
} }
func (s *Server) renderSignIn(w http.ResponseWriter, errMsg string) { func (s *Server) renderSignIn(w http.ResponseWriter, r *http.Request, errMsg string) {
render(w, signInTmpl, map[string]any{ render(w, signInTmpl, map[string]any{
"Title": "Sign in", "Error": errMsg, "OIDC": s.oidc != nil, "Title": "Sign in", "Error": errMsg, "OIDC": s.oidc != nil,
"Pre": s.preSessionToken(w, r),
}) })
} }
+2 -9
View File
@@ -8,7 +8,6 @@ import (
"time" "time"
"cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
) )
const newAdminPassword = "a-much-better-password" const newAdminPassword = "a-much-better-password"
@@ -69,20 +68,14 @@ func TestChangePasswordSwapsThePasswordAndRevokesOtherSessions(t *testing.T) {
func TestChangePasswordRequiresTheSecondFactorWhenEnrolled(t *testing.T) { func TestChangePasswordRequiresTheSecondFactorWhenEnrolled(t *testing.T) {
h := newHarness(t) h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "") tok, _, _ := h.login("admin", adminPassword, "")
_, enroll := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil) sec := h.enrollTOTP(t, tok, adminPassword)
sec := enroll["secret"].(string)
code, _ := totp.Code(sec, time.Now())
if s, _ := h.call("POST", "/api/v1/auth/totp/confirm", tok,
map[string]string{"code": code}); s != http.StatusOK {
t.Fatal("could not enrol the second factor")
}
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{ status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
"current_password": adminPassword, "new_password": newAdminPassword}) "current_password": adminPassword, "new_password": newAdminPassword})
if status != http.StatusUnauthorized || body["code"] != "totp_required" { if status != http.StatusUnauthorized || body["code"] != "totp_required" {
t.Fatalf("change without a code: %d %v", status, body) t.Fatalf("change without a code: %d %v", status, body)
} }
code, _ = totp.Code(sec, time.Now()) code := codeAt(t, sec, 0)
if s, b := h.call("POST", "/api/v1/auth/password", tok, map[string]string{ if s, b := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
"current_password": adminPassword, "new_password": newAdminPassword, "current_password": adminPassword, "new_password": newAdminPassword,
"totp": code}); s != http.StatusOK { "totp": code}); s != http.StatusOK {
+91 -18
View File
@@ -8,7 +8,9 @@ package server
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log" "log"
"net"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -17,6 +19,7 @@ import (
"cc.tiredsysadmin.keeporg/internal/blob" "cc.tiredsysadmin.keeporg/internal/blob"
"cc.tiredsysadmin.keeporg/internal/kdbx" "cc.tiredsysadmin.keeporg/internal/kdbx"
"cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/sqldb"
"cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/store"
) )
@@ -31,9 +34,19 @@ type Config struct {
TLSKey string `json:"tls_key,omitempty"` TLSKey string `json:"tls_key,omitempty"`
OIDC OIDCConfig `json:"oidc"` OIDC OIDCConfig `json:"oidc"`
// MasterKeyFile holds the key that wraps the vault keys the server keeps // TrustedProxies are the reverse proxies whose X-Forwarded-For is
// in order to merge. It belongs outside DataDir: a copy of the data // believed, as addresses or CIDR ranges. Rate limits count per client
// directory alone must not be enough to open a vault. // address, and behind a proxy every request otherwise comes from it.
TrustedProxies []string `json:"trusted_proxies,omitempty"`
// Database is where the server keeps its data: postgres://… for
// PostgreSQL, or sqlite:<path>. The default is a SQLite file in DataDir.
Database string `json:"database"`
// MasterKeyFile holds the key that encrypts everything in the database
// and wraps the vault keys the server keeps in order to merge. It belongs
// outside DataDir and outside the database's backups: either alone must
// not be enough to read anything.
MasterKeyFile string `json:"master_key_file"` MasterKeyFile string `json:"master_key_file"`
// VaultKDF is the work factor of vaults this server creates. The server // VaultKDF is the work factor of vaults this server creates. The server
// pays it three times per merge, and so does every client on every open. // pays it three times per merge, and so does every client on every open.
@@ -49,7 +62,12 @@ func (c *Config) Defaults() {
c.DataDir = "/var/lib/keeporg" c.DataDir = "/var/lib/keeporg"
} }
if c.ExternalURL == "" { if c.ExternalURL == "" {
c.ExternalURL = "http://localhost" + c.Addr // ":8765" means every interface; "127.0.0.1:8765" names its host.
host := c.Addr
if strings.HasPrefix(host, ":") {
host = "localhost" + host
}
c.ExternalURL = "http://" + host
} }
c.ExternalURL = strings.TrimRight(c.ExternalURL, "/") c.ExternalURL = strings.TrimRight(c.ExternalURL, "/")
if c.SessionTTL == 0 { if c.SessionTTL == 0 {
@@ -58,6 +76,9 @@ func (c *Config) Defaults() {
if c.MaxDBSize == 0 { if c.MaxDBSize == 0 {
c.MaxDBSize = 32 << 20 c.MaxDBSize = 32 << 20
} }
if c.Database == "" {
c.Database = "sqlite:" + filepath.Join(c.DataDir, "keeporg.db")
}
if c.MasterKeyFile == "" { if c.MasterKeyFile == "" {
c.MasterKeyFile = "/etc/keeporg/master.key" c.MasterKeyFile = "/etc/keeporg/master.key"
} }
@@ -68,33 +89,42 @@ func (c *Config) Defaults() {
type Server struct { type Server struct {
cfg Config cfg Config
db *sqldb.DB
st *store.Store st *store.Store
blobs *blob.Store blobs *blob.Store
hub *Hub hub *Hub
oidc *oidcProvider oidc *oidcProvider
keyring *secret.Keyring keyring *secret.Keyring
limits *limiter
proxies []*net.IPNet
merges chan struct{} // a slot per merge allowed to run at once
mux *http.ServeMux mux *http.ServeMux
log *log.Logger log *log.Logger
} }
// New opens the server's data directory and wires up its routes. // New opens the server's database and wires up its routes.
func New(cfg Config, lg *log.Logger) (*Server, error) { func New(cfg Config, lg *log.Logger) (*Server, error) {
cfg.Defaults() cfg.Defaults()
st, err := store.Open(filepath.Join(cfg.DataDir, "state.json")) proxies, err := parseProxies(cfg.TrustedProxies)
if err != nil { if err != nil {
return nil, err return nil, err
} }
blobs, err := blob.Open(filepath.Join(cfg.DataDir, "blobs")) // Everything in the database is sealed under the master key, so there
// is no serving without it.
kr, err := secret.LoadKeyring(cfg.MasterKeyFile)
if err != nil {
return nil, fmt.Errorf("master key: %w", err)
}
st, db, err := OpenStore(cfg, kr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
s := &Server{cfg: cfg, st: st, blobs: blobs, hub: NewHub(), mux: http.NewServeMux(), log: lg} s := &Server{cfg: cfg, db: db, st: st, blobs: blob.Open(db, kr.Derive("blobs")),
// Without a master key the server still serves every strategy that does keyring: kr, limits: newLimiter(), proxies: proxies, merges: make(chan struct{}, 2),
// not need to read a vault; only merging and vault creation are refused. hub: NewHub(), mux: http.NewServeMux(), log: lg}
if kr, kerr := secret.LoadKeyring(cfg.MasterKeyFile); kerr != nil { if err := s.importLegacy(); err != nil {
lg.Printf("no master key, so this server cannot create or merge vaults: %v", kerr) s.Close()
} else { return nil, fmt.Errorf("import %s: %w", cfg.DataDir, err)
s.keyring = kr
} }
if cfg.OIDC.Enabled() { if cfg.OIDC.Enabled() {
s.oidc = newOIDCProvider(cfg.OIDC, cfg.ExternalURL+"/oidc/callback") s.oidc = newOIDCProvider(cfg.OIDC, cfg.ExternalURL+"/oidc/callback")
@@ -146,7 +176,30 @@ func (s *Server) Store() *store.Store { return s.st }
func (s *Server) Config() Config { return s.cfg } func (s *Server) Config() Config { return s.cfg }
// Handler is the server's HTTP entry point. // Handler is the server's HTTP entry point.
func (s *Server) Handler() http.Handler { return s.mux } // Handler is the server's HTTP surface, with the security headers every
// response carries.
func (s *Server) Handler() http.Handler {
hsts := strings.HasPrefix(s.cfg.ExternalURL, "https://")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
// The pages have no scripts, no images and no frames; their only
// style is inline. Nothing may frame them, which is what stops a
// sign-out or delete button being clicked through a disguise.
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; "+
"form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
h.Set("X-Frame-Options", "DENY")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
// Pages carry CSRF tokens, invite links and, once, a TOTP secret;
// API answers carry bearer tokens. None of it belongs in a cache.
h.Set("Cache-Control", "no-store")
if hsts {
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
s.mux.ServeHTTP(w, r)
})
}
// StartHousekeeping runs periodic expiry until done is closed. // StartHousekeeping runs periodic expiry until done is closed.
func (s *Server) StartHousekeeping(done <-chan struct{}) { func (s *Server) StartHousekeeping(done <-chan struct{}) {
@@ -215,6 +268,7 @@ func (s *Server) routes() {
m.HandleFunc("POST /invite/{token}", s.handleInviteRedeem) m.HandleFunc("POST /invite/{token}", s.handleInviteRedeem)
m.HandleFunc("GET /oidc/login", s.handleOIDCLogin) m.HandleFunc("GET /oidc/login", s.handleOIDCLogin)
m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback) m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback)
m.HandleFunc("POST /oidc/approve", s.handleOIDCApprove)
// Synchronization. // Synchronization.
m.HandleFunc("GET /api/v1/databases", s.authed(s.handleListDatabases)) m.HandleFunc("GET /api/v1/databases", s.authed(s.handleListDatabases))
@@ -338,16 +392,35 @@ func (s *Server) markSeen(token string, last time.Time) {
}) })
} }
// bearer extracts a token from the Authorization header, or from the // bearer extracts a token from the Authorization header. The event stream
// access_token query parameter, which EventSource clients need because they // alone also takes ?access_token=, which clients before 0.12 sent; a token
// cannot set headers. // in a URL ends up in proxy logs, so nothing else accepts it, and current
// clients send the header there too.
func bearer(r *http.Request) string { func bearer(r *http.Request) string {
h := r.Header.Get("Authorization") h := r.Header.Get("Authorization")
if after, ok := strings.CutPrefix(h, "Bearer "); ok { if after, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(after) return strings.TrimSpace(after)
} }
if r.URL.Path == "/api/v1/events" {
return r.URL.Query().Get("access_token") return r.URL.Query().Get("access_token")
} }
return ""
}
// sessionAlive reports whether a token still belongs to a live session of
// an enabled account, for long requests that outlast their authentication.
func (s *Server) sessionAlive(token string) bool {
alive := false
s.st.Read(func(st *store.State) {
sess := st.Sessions[token]
if sess == nil || time.Now().After(sess.ExpiresAt) {
return
}
u := st.Users[sess.UserID]
alive = u != nil && !u.Disabled
})
return alive
}
func (s *Server) newSession(st *store.State, userID, label string) *store.Session { func (s *Server) newSession(st *store.State, userID, label string) *store.Session {
now := time.Now() now := time.Now()
+101
View File
@@ -0,0 +1,101 @@
package server
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/sqldb"
"cc.tiredsysadmin.keeporg/internal/store"
)
// OpenStore opens the configured database, proves the master key is the one
// it was written with, and loads the metadata. keeporgd -reset-admin uses it
// too, so a reset goes through the same checks as the server.
func OpenStore(cfg Config, kr *secret.Keyring) (*store.Store, *sqldb.DB, error) {
cfg.Defaults()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
db, err := sqldb.Open(ctx, cfg.Database)
if err != nil {
return nil, nil, err
}
if err := db.CheckKey(ctx, kr.Derive("check")); err != nil {
db.Close()
return nil, nil, fmt.Errorf("database %s: %w", db.Where, err)
}
st, err := store.Open(ctx, db, kr.Derive("records"))
if err != nil {
db.Close()
return nil, nil, fmt.Errorf("database %s: %w", db.Where, err)
}
return st, db, nil
}
// Close stops the store's watcher and closes the database.
func (s *Server) Close() {
s.st.Close()
s.db.Close()
}
// legacyState and legacyBlobs are where servers before 0.11 kept everything,
// in the data directory.
const (
legacyState = "state.json"
legacyBlobs = "blobs"
)
// importLegacy moves the data of a server from before 0.11 into the
// database, once: when the database is empty and the old files are there.
// The payloads go first and the metadata last, in one transaction, so a
// crash part-way leaves an empty store and the import simply runs again.
//
// The old files are renamed rather than deleted, so nothing is lost if the
// import turns out wrong. They are not encrypted the way the database is —
// state.json holds session tokens in the clear — so the log asks for them to
// be deleted once the server is known to work.
func (s *Server) importLegacy() error {
statePath := filepath.Join(s.cfg.DataDir, legacyState)
if _, err := os.Stat(statePath); errors.Is(err, fs.ErrNotExist) {
return nil
}
if !s.st.Empty() {
s.log.Printf("WARNING: %s is still there, but the database already holds data, "+
"so it was not imported again. Delete it, and %s, once you no longer need them.",
statePath, filepath.Join(s.cfg.DataDir, legacyBlobs))
return nil
}
old, err := store.ReadLegacy(statePath)
if err != nil {
return err
}
n, err := s.blobs.ImportDir(filepath.Join(s.cfg.DataDir, legacyBlobs))
if err != nil {
return err
}
if err := s.st.Replace(old); err != nil {
return err
}
stamp := time.Now().UTC().Format("20060102-150405")
var renamed []string
for _, name := range []string{legacyState, legacyBlobs} {
from := filepath.Join(s.cfg.DataDir, name)
to := from + ".imported-" + stamp
if err := os.Rename(from, to); err == nil {
renamed = append(renamed, to)
}
}
s.log.Printf("imported %d accounts, %d databases and %d stored versions from %s into %s",
len(old.Users), len(old.Databases), n, s.cfg.DataDir, s.db.Where)
s.log.Printf("the old files were kept as %v. They are not encrypted like the database "+
"(state.json holds session tokens): delete them once this server works", renamed)
return nil
}
// DatabaseWhere is the database's location without any password, for logs.
func (s *Server) DatabaseWhere() string { return s.db.Where }
+99
View File
@@ -0,0 +1,99 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"cc.tiredsysadmin.keeporg/internal/sqldb"
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
"cc.tiredsysadmin.keeporg/internal/store"
)
// A data directory written by a server before 0.11 is imported on the first
// start: accounts, sessions and every stored version, after which the old
// files are set aside.
func TestOldDataDirectoryIsImported(t *testing.T) {
dir := t.TempDir()
old := map[string]any{
"users": map[string]any{"u1": map[string]any{"id": "u1", "username": "alice"}},
"groups": map[string]any{"g1": map[string]any{"id": "g1", "name": "everyone"}},
"databases": map[string]any{"d1": map[string]any{"id": "d1", "name": "Team",
"owner_group_id": "g1", "merge_strategy": "server-wins", "version": 2}},
"sessions": map[string]any{"old-token": map[string]any{"token": "old-token",
"user_id": "u1", "expires_at": time.Now().Add(time.Hour)}},
"memberships": []any{map[string]any{"user_id": "u1", "group_id": "g1", "role": "admin"}},
}
b, _ := json.Marshal(old)
os.WriteFile(filepath.Join(dir, "state.json"), b, 0o600)
os.MkdirAll(filepath.Join(dir, "blobs", "d1"), 0o700)
for v, body := range map[string]string{"1": "first", "2": "second"} {
os.WriteFile(filepath.Join(dir, "blobs", "d1", "0000000000000000000"+v+".kdbx"), []byte(body), 0o600)
}
var logs bytes.Buffer
cfg := Config{DataDir: dir, Database: sqldbtest.URL(t), MasterKeyFile: filepath.Join(t.TempDir(), "mk")}
srv, err := New(cfg, log.New(&logs, "", 0))
if err != nil {
t.Fatal(err)
}
defer srv.Close()
srv.Store().Read(func(st *store.State) {
if st.Users["u1"] == nil || st.Sessions["old-token"] == nil || len(st.Memberships) != 1 {
t.Fatalf("imported state = %+v", st)
}
})
if got, err := srv.blobs.Get("d1", 2); err != nil || string(got) != "second" {
t.Fatalf("imported version 2 = %q, %v", got, err)
}
if _, err := os.Stat(filepath.Join(dir, "state.json")); !errors.Is(err, os.ErrNotExist) {
t.Fatal("state.json was left in place, to be imported again")
}
if !strings.Contains(logs.String(), "delete them") {
t.Fatalf("the log does not ask for the old files to be deleted:\n%s", logs.String())
}
// A second start finds the database populated and imports nothing.
srv.Close()
again, err := New(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
defer again.Close()
again.Store().Read(func(st *store.State) {
if len(st.Users) != 1 {
t.Fatalf("users after restart = %d", len(st.Users))
}
})
}
func TestServerRefusesAnotherMasterKey(t *testing.T) {
url := sqldbtest.URL(t)
cfg := Config{DataDir: t.TempDir(), Database: url, MasterKeyFile: filepath.Join(t.TempDir(), "mk")}
srv, err := New(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
srv.Close()
cfg.MasterKeyFile = filepath.Join(t.TempDir(), "another")
if _, err := New(cfg, log.New(io.Discard, "", 0)); !errors.Is(err, sqldb.ErrWrongKey) {
t.Fatalf("started with a different master key: %v", err)
}
}
func TestServerRefusesToStartWithoutItsMasterKey(t *testing.T) {
t.Setenv("KEEPORG_MASTER_KEY_FILE", filepath.Join(t.TempDir(), "not-mounted"))
cfg := Config{DataDir: t.TempDir(), Database: sqldbtest.URL(t)}
if _, err := New(cfg, log.New(io.Discard, "", 0)); err == nil || !strings.Contains(err.Error(), "master key") {
t.Fatalf("started without its master key: %v", err)
}
}
+33 -7
View File
@@ -1,6 +1,8 @@
package server package server
import ( import (
"context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -10,6 +12,7 @@ import (
"time" "time"
"cc.tiredsysadmin.keeporg/internal/blob" "cc.tiredsysadmin.keeporg/internal/blob"
"cc.tiredsysadmin.keeporg/internal/kdbx"
"cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/store"
) )
@@ -145,8 +148,20 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
return return
} }
// The server opens a merge vault, so a file it will one day have to
// open must stay within what it can afford to: a header demanding a
// billion KDF rounds is refused here, before it is stored and before
// any merge tries it.
if strategy == store.StrategyMerge {
if err := kdbx.Inspect(data); err != nil {
fail(w, http.StatusBadRequest, "invalid_kdbx",
"this server merges this vault, and cannot accept this file: "+err.Error())
return
}
}
if merging { if merging {
s.mergePush(w, u, id, current, data) s.mergePush(w, r, u, id, current, data)
return return
} }
@@ -156,7 +171,9 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
watchers []string watchers []string
name string name string
) )
err = s.st.Update(func(st *store.State) error { write := false
err = s.st.UpdateWith(func(st *store.State) error {
write = false
d := st.Databases[id] d := st.Databases[id]
if d == nil { if d == nil {
return errors.New("no such database") return errors.New("no such database")
@@ -169,15 +186,17 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
next, unchanged, name = d.Version, true, d.Name next, unchanged, name = d.Version, true, d.Name
return nil return nil
} }
next = d.Version + 1 next, write = d.Version+1, true
if err := s.blobs.Put(id, next, data); err != nil {
return err
}
d.Version, d.SHA256, d.Size = next, sum, int64(len(data)) d.Version, d.SHA256, d.Size = next, sum, int64(len(data))
d.UpdatedAt, d.UpdatedBy = time.Now(), u.Username d.UpdatedAt, d.UpdatedBy = time.Now(), u.Username
name = d.Name name = d.Name
watchers = st.Watchers(id) watchers = st.Watchers(id)
return nil return nil
}, func(ctx context.Context, tx *sql.Tx) error {
if !write {
return nil
}
return s.blobs.PutTx(ctx, tx, id, next, data)
}) })
var ce conflictError var ce conflictError
switch { switch {
@@ -190,7 +209,8 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
}) })
return return
case err != nil: case err != nil:
fail(w, http.StatusInternalServerError, "internal", err.Error()) s.log.Printf("push %s: %v", id, err)
fail(w, http.StatusInternalServerError, "internal", "the push could not be stored; try again")
return return
} }
if !unchanged { if !unchanged {
@@ -212,6 +232,7 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request, u *store.U
fail(w, http.StatusInternalServerError, "internal", "streaming unsupported") fail(w, http.StatusInternalServerError, "internal", "streaming unsupported")
return return
} }
token := bearer(r)
id, ch := s.hub.Subscribe(u.ID) id, ch := s.hub.Subscribe(u.ID)
defer s.hub.Unsubscribe(id) defer s.hub.Unsubscribe(id)
@@ -237,6 +258,11 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request, u *store.U
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, ev.encode()) fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, ev.encode())
flusher.Flush() flusher.Flush()
case <-keepalive.C: case <-keepalive.C:
// A stream outlives the request that authenticated it: end it
// once the session is revoked or the account disabled.
if !s.sessionAlive(token) {
return
}
fmt.Fprint(w, ": keepalive\n\n") fmt.Fprint(w, ": keepalive\n\n")
flusher.Flush() flusher.Flush()
} }
+11 -4
View File
@@ -40,6 +40,13 @@ func (s *Server) createVault(u *store.User, name, groupID string, strategy store
case strategy.NeedsKey() && s.keyring == nil: case strategy.NeedsKey() && s.keyring == nil:
return dbView{}, errNoKeyring return dbView{}, errNoKeyring
} }
// Permission first: the key derivation below is deliberately costly,
// and nobody who could not create the vault should get to spend it.
var allowed bool
s.st.Read(func(st *store.State) { allowed = st.Groups[groupID] != nil && st.IsGroupAdmin(u.ID, groupID) })
if !allowed {
return dbView{}, errForbidden
}
// Build the empty database before touching any state, so a failure here // Build the empty database before touching any state, so a failure here
// leaves nothing behind. // leaves nothing behind.
@@ -125,16 +132,16 @@ func (s *Server) handleSetVaultKey(w http.ResponseWriter, r *http.Request, u *st
} }
var ( var (
version int64 version int64
perm store.Perm access error
) )
s.st.Read(func(st *store.State) { s.st.Read(func(st *store.State) {
perm = st.DatabasePerm(u.ID, id) access = adminsDatabase(st, u, id)
if d := st.Databases[id]; d != nil { if d := st.Databases[id]; d != nil {
version = d.Version version = d.Version
} }
}) })
if perm != store.PermAdmin { if access != nil {
deny(w, errForbidden) deny(w, access)
return return
} }
if !req.Forget { if !req.Forget {
+44 -17
View File
@@ -62,7 +62,7 @@ func (s *Server) webPage(fn webHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
sess := s.webUser(r) sess := s.webUser(r)
if sess == nil { if sess == nil {
s.renderSignIn(w, "") s.renderSignIn(w, r, "")
return return
} }
fn(w, r, sess) fn(w, r, sess)
@@ -75,7 +75,7 @@ func (s *Server) webForm(fn webHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
sess := s.webUser(r) sess := s.webUser(r)
if sess == nil { if sess == nil {
s.renderSignIn(w, "") s.renderSignIn(w, r, "")
return return
} }
if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) { if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) {
@@ -108,7 +108,28 @@ func (s *Server) handleWebHome(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/web/vaults", http.StatusFound) http.Redirect(w, r, "/web/vaults", http.StatusFound)
return return
} }
s.renderSignIn(w, "") s.renderSignIn(w, r, "")
}
// preCookie carries a token that the sign-in form must echo. Without it a
// page elsewhere could post the form with its own credentials and sign a
// visitor in to the attacker's account, where whatever they then create
// lands. Strict, so a cross-site post does not carry it.
const preCookie = "keeporg_pre"
// preSessionToken returns this browser's sign-in form token, issuing one if
// it has none.
func (s *Server) preSessionToken(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie(preCookie); err == nil && len(c.Value) == 64 {
return c.Value
}
tok := secret.Token()
http.SetCookie(w, &http.Cookie{
Name: preCookie, Value: tok, Path: "/web", HttpOnly: true,
Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"),
SameSite: http.SameSiteStrictMode,
})
return tok
} }
func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) { func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) {
@@ -116,25 +137,28 @@ func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusBadRequest, "bad_request", err.Error()) fail(w, http.StatusBadRequest, "bad_request", err.Error())
return return
} }
var token string if c, err := r.Cookie(preCookie); err != nil || !secret.Equal(c.Value, r.FormValue("pre")) {
err := s.st.Update(func(st *store.State) error { s.renderSignIn(w, r, "The sign-in form had expired. Sign in again.")
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), return
r.FormValue("totp"))
if err != nil {
return err
} }
var token string
err := s.signIn(r, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"),
func(st *store.State, u *store.User) error {
token = s.newBrowserSession(st, u.ID, r).Token token = s.newBrowserSession(st, u.ID, r).Token
return nil return nil
}) })
var throttled *errThrottled
switch { switch {
case errors.As(err, &throttled):
s.renderSignIn(w, r, throttled.Error())
case errors.Is(err, errTOTPRequired): case errors.Is(err, errTOTPRequired):
s.renderSignIn(w, "This account needs its one-time code.") s.renderSignIn(w, r, "This account needs its one-time code.")
case errors.Is(err, errTOTPInvalid): case errors.Is(err, errTOTPInvalid):
s.renderSignIn(w, "That one-time code was rejected.") s.renderSignIn(w, r, "That one-time code was rejected.")
case errors.Is(err, errBadCredentials): case errors.Is(err, errBadCredentials):
s.renderSignIn(w, "Unknown user or wrong password.") s.renderSignIn(w, r, "Unknown user or wrong password.")
case err != nil: case err != nil:
s.renderSignIn(w, err.Error()) s.renderSignIn(w, r, err.Error())
default: default:
s.setWebCookie(w, token, s.cfg.SessionTTL) s.setWebCookie(w, token, s.cfg.SessionTTL)
http.Redirect(w, r, "/web/vaults", http.StatusFound) http.Redirect(w, r, "/web/vaults", http.StatusFound)
@@ -246,15 +270,18 @@ func (s *Server) handleWebDeleteVaultPage(w http.ResponseWriter, r *http.Request
func (s *Server) handleWebDeleteVault(w http.ResponseWriter, r *http.Request, sess *webSession) { func (s *Server) handleWebDeleteVault(w http.ResponseWriter, r *http.Request, sess *webSession) {
id := r.PathValue("id") id := r.PathValue("id")
name, err := s.deleteDatabase(sess.user, id, r.FormValue("password"), name, err := s.deleteDatabase(r, sess.user, id, r.FormValue("password"),
r.FormValue("totp"), r.FormValue("confirm_name")) r.FormValue("totp"), r.FormValue("confirm_name"))
var throttled *errThrottled
switch { switch {
case errors.As(err, &throttled):
s.renderDeleteVault(w, sess, id, throttled.Error())
case errors.Is(err, errReauth):
s.renderDeleteVault(w, sess, id, "Enter your account password.")
case errors.Is(err, errTOTPRequired): case errors.Is(err, errTOTPRequired):
s.renderDeleteVault(w, sess, id, "Enter your one-time code as well.") s.renderDeleteVault(w, sess, id, "Enter your one-time code as well.")
case errors.Is(err, errTOTPInvalid):
s.renderDeleteVault(w, sess, id, "That one-time code was rejected.")
case errors.Is(err, errBadCredentials): case errors.Is(err, errBadCredentials):
s.renderDeleteVault(w, sess, id, "That is not your account password.") s.renderDeleteVault(w, sess, id, "Your account password or one-time code is wrong.")
case errors.Is(err, errConfirmName): case errors.Is(err, errConfirmName):
s.renderDeleteVault(w, sess, id, "Type the vault's name exactly as it is shown.") s.renderDeleteVault(w, sess, id, "Type the vault's name exactly as it is shown.")
case err != nil: case err != nil:
+61 -3
View File
@@ -41,8 +41,18 @@ func (b *browser) get(path string) (int, string) {
return resp.StatusCode, string(body) return resp.StatusCode, string(body)
} }
var prePattern = regexp.MustCompile(`name="pre" value="([^"]+)"`)
func (b *browser) post(path string, form url.Values) (int, string) { func (b *browser) post(path string, form url.Values) (int, string) {
b.t.Helper() b.t.Helper()
// The sign-in form carries a token from the page that showed it; fill
// it in the way a browser would by having loaded the page first.
if path == "/web/signin" && form.Get("pre") == "" {
_, page := b.get("/web")
if m := prePattern.FindStringSubmatch(page); m != nil {
form.Set("pre", m[1])
}
}
resp, err := b.http.PostForm(b.h.http.URL+path, form) resp, err := b.http.PostForm(b.h.http.URL+path, form)
if err != nil { if err != nil {
b.t.Fatal(err) b.t.Fatal(err)
@@ -107,11 +117,49 @@ func TestWebSignInRejectsWrongPassword(t *testing.T) {
} }
} }
// A sign-in form posted from elsewhere carries no pre-session cookie and is
// refused, so a page cannot sign a visitor in to an account of its choosing.
func TestWebSignInRefusesACrossSitePost(t *testing.T) {
h := newHarness(t)
resp, err := h.http.Client().PostForm(h.http.URL+"/web/signin", url.Values{
"username": {"admin"}, "password": {adminPassword}, "pre": {strings.Repeat("a", 64)}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
for _, c := range resp.Cookies() {
if c.Name == webCookie && c.Value != "" {
t.Fatal("a cross-site sign-in was accepted")
}
}
}
func TestEveryResponseCarriesSecurityHeaders(t *testing.T) {
h := newHarness(t)
for _, path := range []string{"/web", "/device", "/api/v1/databases", "/healthz"} {
resp, err := h.http.Client().Get(h.http.URL + path)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
for _, k := range []string{"Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Cache-Control"} {
if resp.Header.Get(k) == "" {
t.Errorf("%s: no %s", path, k)
}
}
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'none'") {
t.Errorf("%s: frames allowed", path)
}
}
}
func TestWebSessionCookieIsHardened(t *testing.T) { func TestWebSessionCookieIsHardened(t *testing.T) {
h := newHarness(t) h := newHarness(t)
b := newBrowser(t, h) b := newBrowser(t, h)
_, page := b.get("/web")
resp, err := b.http.PostForm(h.http.URL+"/web/signin", url.Values{ resp, err := b.http.PostForm(h.http.URL+"/web/signin", url.Values{
"username": {"admin"}, "password": {adminPassword}}) "username": {"admin"}, "password": {adminPassword},
"pre": {prePattern.FindStringSubmatch(page)[1]}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -138,17 +186,27 @@ func TestWebSessionCookieIsHardened(t *testing.T) {
func TestWebCookieIsSecureBehindHTTPS(t *testing.T) { func TestWebCookieIsSecureBehindHTTPS(t *testing.T) {
h := newHarness(t) h := newHarness(t)
h.srv.cfg.ExternalURL = "https://keeporg.example.com" h.srv.cfg.ExternalURL = "https://keeporg.example.com"
resp, err := h.http.Client().PostForm(h.http.URL+"/web/signin", url.Values{ form := url.Values{"username": {"admin"}, "password": {adminPassword}, "pre": {strings.Repeat("a", 64)}}
"username": {"admin"}, "password": {adminPassword}}) req, _ := http.NewRequest("POST", h.http.URL+"/web/signin", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: preCookie, Value: strings.Repeat("a", 64)})
resp, err := h.http.Client().Do(req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer resp.Body.Close()
var found bool
for _, c := range resp.Request.Response.Cookies() { for _, c := range resp.Request.Response.Cookies() {
if c.Name == webCookie {
found = true
}
if c.Name == webCookie && !c.Secure { if c.Name == webCookie && !c.Secure {
t.Fatal("the cookie is not marked Secure although the server is https") t.Fatal("the cookie is not marked Secure although the server is https")
} }
} }
if !found {
t.Fatal("no session cookie was issued")
}
} }
func TestWebCreatesAVaultThatOpensWithItsPassword(t *testing.T) { func TestWebCreatesAVaultThatOpensWithItsPassword(t *testing.T) {
+55 -21
View File
@@ -32,6 +32,9 @@ type webAccount struct {
TOTPKey string TOTPKey string
TOTPLink template.URL TOTPLink template.URL
Sessions []webSessionRow Sessions []webSessionRow
// SSOLink starts linking single sign-on to this account, when the
// server has a provider and the account is not linked yet.
SSOLink string
} }
type webMembership struct { type webMembership struct {
@@ -61,6 +64,9 @@ func (s *Server) accountView(sess *webSession) webAccount {
Since: u.CreatedAt.UTC().Format("2006-01-02"), Since: u.CreatedAt.UTC().Format("2006-01-02"),
HasPassword: u.PassHash != "", TOTPOn: u.TOTPConfirmed, HasPassword: u.PassHash != "", TOTPOn: u.TOTPConfirmed,
} }
if s.oidc != nil && u.OIDCSubject == "" && !u.Disabled {
v.SSOLink = "/oidc/login?link=1&csrf=" + url.QueryEscape(sess.csrf)
}
if u.OIDCIssuer != "" { if u.OIDCIssuer != "" {
v.SSO = u.OIDCIssuer v.SSO = u.OIDCIssuer
if iss, err := url.Parse(u.OIDCIssuer); err == nil && iss.Host != "" { if iss, err := url.Parse(u.OIDCIssuer); err == nil && iss.Host != "" {
@@ -134,6 +140,14 @@ func (s *Server) handleWebAccount(w http.ResponseWriter, r *http.Request, sess *
// handleWebAccountAction applies one control of the settings page. // handleWebAccountAction applies one control of the settings page.
func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request, sess *webSession) { func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request, sess *webSession) {
var notice string var notice string
// Changes that need the password and code are proven outside the
// store's lock, then applied inside it.
switch r.FormValue("action") {
case "password", "totp-begin", "totp-disable":
err := s.accountReconfirmed(r, sess, &notice)
s.finishAccountAction(w, r, sess, err, notice)
return
}
err := s.st.Update(func(st *store.State) error { err := s.st.Update(func(st *store.State) error {
switch r.FormValue("action") { switch r.FormValue("action") {
case "revoke": case "revoke":
@@ -144,21 +158,6 @@ func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request,
case "revoke-others": case "revoke-others":
n := revokeOtherSessions(st, sess.user.ID, sess.token) n := revokeOtherSessions(st, sess.user.ID, sess.token)
notice = fmt.Sprintf("Signed out %d other %s.", n, plural(n, "session", "sessions")) notice = fmt.Sprintf("Signed out %d other %s.", n, plural(n, "session", "sessions"))
case "password":
next := r.FormValue("new_password")
if next != r.FormValue("new_password2") {
return errors.New("the two new passwords do not match")
}
if err := changePassword(st, sess.user.Username, r.FormValue("current_password"),
r.FormValue("totp"), next, sess.token); err != nil {
return err
}
notice = "Password changed. Your other sessions were signed out."
case "totp-begin":
if _, err := beginTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil {
return err
}
notice = "Add the key below to your authenticator app, then enter the code it shows."
case "totp-confirm": case "totp-confirm":
if err := confirmTOTP(st, sess.user.ID, r.FormValue("code")); err != nil { if err := confirmTOTP(st, sess.user.ID, r.FormValue("code")); err != nil {
return err return err
@@ -167,16 +166,45 @@ func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request,
case "totp-cancel": case "totp-cancel":
cancelTOTP(st, sess.user.ID) cancelTOTP(st, sess.user.ID)
notice = "Setup cancelled. Nothing changed." notice = "Setup cancelled. Nothing changed."
case "totp-disable":
if err := disableTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil {
return err
}
notice = "Two-factor authentication is off."
default: default:
return errBadAction return errBadAction
} }
return nil return nil
}) })
s.finishAccountAction(w, r, sess, err, notice)
}
// accountReconfirmed applies the settings changes that need the password,
// and the one-time code when one is on.
func (s *Server) accountReconfirmed(r *http.Request, sess *webSession, notice *string) error {
switch r.FormValue("action") {
case "password":
next := r.FormValue("new_password")
if next != r.FormValue("new_password2") {
return errors.New("the two new passwords do not match")
}
if err := s.changePassword(r, sess.user, r.FormValue("current_password"),
r.FormValue("totp"), next, sess.token); err != nil {
return err
}
*notice = "Password changed. Your other sessions were signed out."
case "totp-begin":
if err := s.reconfirm(r, sess.user, r.FormValue("password"), r.FormValue("totp"),
func(st *store.State, u *store.User) error { _, err := beginTOTP(st, u.ID); return err }); err != nil {
return err
}
*notice = "Add the key below to your authenticator app, then enter the code it shows."
case "totp-disable":
if err := s.reconfirm(r, sess.user, r.FormValue("password"), r.FormValue("totp"),
func(st *store.State, u *store.User) error { return disableTOTP(st, u.ID) }); err != nil {
return err
}
*notice = "Two-factor authentication is off."
}
return nil
}
func (s *Server) finishAccountAction(w http.ResponseWriter, r *http.Request, sess *webSession, err error, notice string) {
if err != nil { if err != nil {
s.renderAccount(w, sess, accountError(err), "") s.renderAccount(w, sess, accountError(err), "")
return return
@@ -191,13 +219,16 @@ func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request,
} }
func accountError(err error) string { func accountError(err error) string {
var throttled *errThrottled
switch { switch {
case errors.As(err, &throttled):
return throttled.Error()
case errors.Is(err, errReauth), errors.Is(err, errTOTPRequired): case errors.Is(err, errReauth), errors.Is(err, errTOTPRequired):
return "Confirm with your password and your current one-time code." return "Confirm with your password and your current one-time code."
case errors.Is(err, errTOTPInvalid): case errors.Is(err, errTOTPInvalid):
return "That one-time code was rejected. Check the clock on the device that shows it." return "That one-time code was rejected. Check the clock on the device that shows it."
case errors.Is(err, errBadCredentials): case errors.Is(err, errBadCredentials):
return "The password is wrong." return "The password or the one-time code is wrong."
case errors.Is(err, errNoSuchSession): case errors.Is(err, errNoSuchSession):
return "That session is already signed out." return "That session is already signed out."
} }
@@ -232,6 +263,7 @@ var accountTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{if .HasPassword}}<span class="tag">password</span>{{end}} {{if .HasPassword}}<span class="tag">password</span>{{end}}
{{if .TOTPOn}}<span class="tag">one-time code</span>{{end}} {{if .TOTPOn}}<span class="tag">one-time code</span>{{end}}
{{if .SSO}}<span class="tag">single sign-on</span> <small>via {{.SSO}}</small>{{end}} {{if .SSO}}<span class="tag">single sign-on</span> <small>via {{.SSO}}</small>{{end}}
{{if .SSOLink}}<br><a href="{{.SSOLink}}">Link single sign-on to this account</a>{{end}}
</td></tr> </td></tr>
<tr><th>Role</th><td>{{if .ServerAdmin}}server administrator{{else}}member{{end}}</td></tr> <tr><th>Role</th><td>{{if .ServerAdmin}}server administrator{{else}}member{{end}}</td></tr>
<tr><th>Groups</th><td>{{range $i, $g := .Groups}}{{if $i}}, {{end}}{{$g.Name}} <small>({{$g.Role}})</small>{{else}}none{{end}}</td></tr> <tr><th>Groups</th><td>{{range $i, $g := .Groups}}{{if $i}}, {{end}}{{$g.Name}} <small>({{$g.Role}})</small>{{else}}none{{end}}</td></tr>
@@ -310,6 +342,8 @@ each sign-in also needs a code from an app on your phone.</p>
<form method="post" action="/web/account"> <form method="post" action="/web/account">
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="totp-begin"> <input type="hidden" name="action" value="totp-begin">
<label for="tb_password">Your password</label>
<input id="tb_password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Set up two-factor authentication</button> <button type="submit">Set up two-factor authentication</button>
</form> </form>
{{end}} {{end}}
+10 -2
View File
@@ -362,10 +362,18 @@ func userRows(st *store.State, u *store.User) []webUserRow {
case target.OIDCSubject != "": case target.OIDCSubject != "":
signIn = "single sign-on" signIn = "single sign-on"
} }
out = append(out, webUserRow{ID: target.ID, Username: target.Username, row := webUserRow{ID: target.ID, Username: target.Username,
Email: target.Email, SignIn: signIn, Groups: strings.Join(mine, ", "), Email: target.Email, SignIn: signIn, Groups: strings.Join(mine, ", "),
TOTP: target.TOTPConfirmed, ServerAdmin: target.ServerAdmin, TOTP: target.TOTPConfirmed, ServerAdmin: target.ServerAdmin,
Disabled: target.Disabled, Self: target.ID == u.ID}) Disabled: target.Disabled, Self: target.ID == u.ID}
// A group administrator manages access, not accounts: how somebody
// signs in, their address and whether they run the server are no
// business of theirs, and a map of weak accounts to whoever can add
// people to a group.
if !u.ServerAdmin && !row.Self {
row.Email, row.SignIn, row.TOTP, row.ServerAdmin = "", "", false, false
}
out = append(out, row)
} }
return out return out
} }
+280
View File
@@ -0,0 +1,280 @@
// Package sqldb is the server's connection to its database: SQLite for a
// single node with nothing to set up, PostgreSQL for a deployment that has a
// database service. Both are reached through database/sql with pure-Go
// drivers, so the server still builds without cgo.
//
// The schema is three tables and deliberately says nothing about the data:
//
// keeporg_meta name → value: schema version, write generation, key check
// keeporg_records id → sealed record (one user, group, session, …)
// keeporg_blobs (ref, version) → sealed KDBX payload
//
// Every row is encrypted by the caller before it gets here, and named by a
// keyed digest rather than by anything readable. What the database, its
// backups and its administrators can see is how many rows there are and how
// big they are.
package sqldb
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
_ "github.com/jackc/pgx/v5/stdlib" // registers "pgx"
_ "modernc.org/sqlite" // registers "sqlite"
"cc.tiredsysadmin.keeporg/internal/secret"
)
// Dialect is the SQL engine behind a DB.
type Dialect string
const (
SQLite Dialect = "sqlite"
Postgres Dialect = "postgres"
)
// schemaVersion is the layout this code reads and writes.
const schemaVersion = 1
// DB is an open, migrated database.
type DB struct {
*sql.DB
Dialect Dialect
// Where is the database's location with any password removed, for logs.
Where string
}
// Open connects to a database and brings its schema up to date. The URL is
// postgres://… or postgresql://… for PostgreSQL, and sqlite:<path> or a bare
// path for SQLite, whose file is created owner-only if it does not exist.
func Open(ctx context.Context, dbURL string) (*DB, error) {
var (
db *DB
err error
)
switch {
case strings.HasPrefix(dbURL, "postgres://"), strings.HasPrefix(dbURL, "postgresql://"):
db, err = openPostgres(dbURL)
default:
db, err = openSQLite(strings.TrimPrefix(dbURL, "sqlite:"))
}
if err != nil {
return nil, err
}
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("database %s: %w", db.Where, err)
}
if err := db.migrate(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("database %s: %w", db.Where, err)
}
return db, nil
}
func openPostgres(dbURL string) (*DB, error) {
sqldb, err := sql.Open("pgx", dbURL)
if err != nil {
return nil, err
}
where := dbURL
if u, err := url.Parse(dbURL); err == nil {
where = u.Redacted()
}
return &DB{DB: sqldb, Dialect: Postgres, Where: where}, nil
}
func openSQLite(path string) (*DB, error) {
if path == "" {
return nil, errors.New("sqlite: no database path")
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
// Created here so the file is owner-only from its first byte; SQLite
// would otherwise create it with the process umask.
if f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600); err != nil {
return nil, err
} else {
f.Close()
}
// WAL lets the blob reads of synchronization run beside a write. FULL
// synchronous is the durable setting, and the right one for passwords.
// An immediate transaction takes the write lock at BEGIN, which is what
// makes the generation check in a write transaction meaningful.
dsn := "file:" + path +
"?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)" +
"&_txlock=immediate"
sqldb, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
return &DB{DB: sqldb, Dialect: SQLite, Where: path}, nil
}
func (db *DB) blobType() string {
if db.Dialect == Postgres {
return "BYTEA"
}
return "BLOB"
}
func (db *DB) migrate(ctx context.Context) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS keeporg_meta (
name TEXT PRIMARY KEY,
value TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS keeporg_records (
id TEXT PRIMARY KEY,
body ` + db.blobType() + ` NOT NULL)`,
`CREATE TABLE IF NOT EXISTS keeporg_blobs (
ref TEXT NOT NULL,
version BIGINT NOT NULL,
body ` + db.blobType() + ` NOT NULL,
PRIMARY KEY (ref, version))`,
}
return db.Tx(ctx, func(tx *sql.Tx) error {
for _, s := range stmts {
if _, err := tx.ExecContext(ctx, s); err != nil {
return err
}
}
v, err := db.meta(ctx, tx, "schema")
if err != nil {
return err
}
switch {
case v == "":
if err := db.setMeta(ctx, tx, "schema", strconv.Itoa(schemaVersion)); err != nil {
return err
}
return db.setMeta(ctx, tx, "generation", "0")
case v != strconv.Itoa(schemaVersion):
return fmt.Errorf("schema version %s is not one this server knows (%d); "+
"it was written by a newer keeporgd", v, schemaVersion)
}
return nil
})
}
// Q rewrites ?-placeholders as $1, $2, … for PostgreSQL. Statements in this
// code base are written once, with ?, and pass through here.
func (db *DB) Q(q string) string {
if db.Dialect != Postgres {
return q
}
var b strings.Builder
n := 0
for _, r := range q {
if r == '?' {
n++
b.WriteString("$" + strconv.Itoa(n))
continue
}
b.WriteRune(r)
}
return b.String()
}
// Tx runs fn in a transaction, committing when it returns nil.
func (db *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := fn(tx); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
// Querier is what both *sql.DB and *sql.Tx offer.
type Querier interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
func (db *DB) meta(ctx context.Context, q Querier, name string) (string, error) {
var v string
err := q.QueryRowContext(ctx, db.Q(`SELECT value FROM keeporg_meta WHERE name = ?`), name).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return v, err
}
func (db *DB) setMeta(ctx context.Context, q Querier, name, value string) error {
_, err := q.ExecContext(ctx, db.Q(`INSERT INTO keeporg_meta (name, value) VALUES (?, ?)
ON CONFLICT (name) DO UPDATE SET value = excluded.value`), name, value)
return err
}
// Generation is the number of committed writes to the records. A process
// that holds the records in memory compares it with its own to tell whether
// another process has written since.
func (db *DB) Generation(ctx context.Context, q Querier) (int64, error) {
v, err := db.meta(ctx, q, "generation")
if err != nil {
return 0, err
}
return strconv.ParseInt(v, 10, 64)
}
// LockGeneration reads the generation inside a write transaction and holds
// it, so no other writer can commit in between. On PostgreSQL that is a row
// lock; a SQLite write transaction already holds the whole database.
func (db *DB) LockGeneration(ctx context.Context, tx *sql.Tx) (int64, error) {
q := `SELECT value FROM keeporg_meta WHERE name = 'generation'`
if db.Dialect == Postgres {
q += ` FOR UPDATE`
}
var v string
if err := tx.QueryRowContext(ctx, q).Scan(&v); err != nil {
return 0, err
}
return strconv.ParseInt(v, 10, 64)
}
// SetGeneration records a new generation inside the write that made it.
func (db *DB) SetGeneration(ctx context.Context, tx *sql.Tx, gen int64) error {
return db.setMeta(ctx, tx, "generation", strconv.FormatInt(gen, 10))
}
// ErrWrongKey means the database was encrypted under a different master
// key. Carrying on would write rows nobody could read alongside rows this
// key cannot, so it is fatal.
var ErrWrongKey = errors.New("this master key does not open this database; " +
"start with the key it was created with")
// CheckKey proves that k is the key the database was written with, by
// opening a sealed marker stored at its first start. A new database gets the
// marker now.
func (db *DB) CheckKey(ctx context.Context, k *secret.Keyring) error {
const aad = "keeporg key check"
v, err := db.meta(ctx, db, "key_check")
if err != nil {
return err
}
if v == "" {
sealed := k.Seal([]byte(aad), []byte("keeporg"))
return db.setMeta(ctx, db, "key_check", base64.StdEncoding.EncodeToString(sealed))
}
raw, err := base64.StdEncoding.DecodeString(v)
if err != nil {
return ErrWrongKey
}
if _, err := k.Open([]byte(aad), raw); err != nil {
return ErrWrongKey
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
// Package sqldbtest gives tests a database of their own: a SQLite file in a
// temporary directory, or, when KEEPORG_TEST_POSTGRES names a PostgreSQL
// server, a fresh database on it that is dropped afterwards. Every test that
// goes through here runs against PostgreSQL by setting one variable.
package sqldbtest
import (
"context"
"database/sql"
"net/url"
"os"
"path/filepath"
"testing"
"cc.tiredsysadmin.keeporg/internal/secret"
_ "github.com/jackc/pgx/v5/stdlib"
)
// PostgresEnv names the PostgreSQL server tests use instead of SQLite, as a
// URL whose user may create databases.
const PostgresEnv = "KEEPORG_TEST_POSTGRES"
// URL returns a database URL that is empty and private to t.
func URL(t testing.TB) string {
t.Helper()
admin := os.Getenv(PostgresEnv)
if admin == "" {
return "sqlite:" + filepath.Join(t.TempDir(), "keeporg.db")
}
u, err := url.Parse(admin)
if err != nil {
t.Fatalf("%s: %v", PostgresEnv, err)
}
name := "keeporg_test_" + secret.ID()
db, err := sql.Open("pgx", admin)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err := db.ExecContext(ctx, "CREATE DATABASE "+name); err != nil {
db.Close()
t.Fatalf("create test database: %v", err)
}
t.Cleanup(func() {
_, _ = db.ExecContext(ctx, "DROP DATABASE IF EXISTS "+name+" WITH (FORCE)")
db.Close()
})
u.Path = "/" + name
return u.String()
}
+2 -2
View File
@@ -124,8 +124,8 @@ func (s *State) Assignments(userID string) []Assignment {
// Watchers lists the user IDs that must be told when a database changes. // Watchers lists the user IDs that must be told when a database changes.
func (s *State) Watchers(dbID string) []string { func (s *State) Watchers(dbID string) []string {
out := []string{} out := []string{}
for id := range s.Users { for id, u := range s.Users {
if s.DatabasePerm(id, dbID) != PermNone { if !u.Disabled && s.DatabasePerm(id, dbID) != PermNone {
out = append(out, id) out = append(out, id)
} }
} }
+13
View File
@@ -84,6 +84,9 @@ type User struct {
OIDCSubject string `json:"oidc_subject,omitempty"` OIDCSubject string `json:"oidc_subject,omitempty"`
TOTPSecret string `json:"totp_secret,omitempty"` TOTPSecret string `json:"totp_secret,omitempty"`
TOTPConfirmed bool `json:"totp_confirmed"` TOTPConfirmed bool `json:"totp_confirmed"`
// TOTPLastStep is the time step of the last one-time code accepted, so
// the same code cannot be used twice.
TOTPLastStep int64 `json:"totp_last_step,omitempty"`
ServerAdmin bool `json:"server_admin"` ServerAdmin bool `json:"server_admin"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -175,6 +178,16 @@ type DeviceAuth struct {
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
SessionToken string `json:"session_token,omitempty"` SessionToken string `json:"session_token,omitempty"`
OIDCState string `json:"oidc_state,omitempty"` OIDCState string `json:"oidc_state,omitempty"`
// PKCEVerifier proves to the provider that this server started the
// round trip that produced a code.
PKCEVerifier string `json:"pkce_verifier,omitempty"`
// LinkUserID marks a round trip that links an identity to this account
// rather than signing in.
LinkUserID string `json:"link_user_id,omitempty"`
// PendingUserID and ApprovalNonce hold a single sign-on approval that
// identified its person but awaits their explicit yes.
PendingUserID string `json:"pending_user_id,omitempty"`
ApprovalNonce string `json:"approval_nonce,omitempty"`
// ReturnURL is where the approval page sends the person back to: the // ReturnURL is where the approval page sends the person back to: the
// client's own URL scheme, for a phone whose browser would otherwise be // client's own URL scheme, for a phone whose browser would otherwise be
// a dead end. Empty for clients that need no hand-back. // a dead end. Empty for clients that need no hand-back.
+356 -46
View File
@@ -1,46 +1,73 @@
package store package store
import ( import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "sort"
"sync" "sync"
"time" "time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/sqldb"
) )
// Store is the single writer of the metadata document. Every mutation runs on // Store is the server's metadata. It is held in memory as one State, which
// a private copy of the state and is swapped in only once it has reached the // is what every access decision reads, and persisted in the database as one
// disk, so a reader never observes a half-applied update and a crash never // sealed row per object.
// leaves a partial file. //
// Every mutation runs on a private copy of the state; the rows that changed
// are written in one transaction, and the copy is swapped in only once that
// transaction has committed. A reader never observes a half-applied update,
// and a failed one changes nothing.
//
// Several processes may share one database. Each write first checks the
// database's generation: if another process has committed since this one
// last loaded, the state is reloaded and the mutation applied again on top.
// A background check picks up other processes' writes within a couple of
// seconds, so a session revoked through one process stops working in all.
type Store struct { type Store struct {
path string db *sqldb.DB
key *secret.Keyring
mu sync.RWMutex mu sync.RWMutex
s *State s *State
gen int64
rows map[string][32]byte // row id → digest of the plaintext last stored
stop chan struct{}
done chan struct{}
} }
// Open loads the state document at path, creating an empty one if absent. // watchInterval is how often the store checks for writes by other processes.
func Open(path string) (*Store, error) { const watchInterval = 2 * time.Second
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
// Open loads the state from db. key seals and names the rows; it must be the
// key the rows were written with, which sqldb.CheckKey establishes first.
func Open(ctx context.Context, db *sqldb.DB, key *secret.Keyring) (*Store, error) {
st := &Store{db: db, key: key, stop: make(chan struct{}), done: make(chan struct{})}
if err := st.reload(ctx); err != nil {
return nil, err return nil, err
} }
st := &Store{path: path, s: newState()} go st.watch()
b, err := os.ReadFile(path)
switch {
case os.IsNotExist(err):
return st, st.persist(st.s)
case err != nil:
return nil, err
}
s := newState()
if err := json.Unmarshal(b, s); err != nil {
return nil, fmt.Errorf("store: parse %s: %w", path, err)
}
s.init()
st.s = s
return st, nil return st, nil
} }
// Close stops watching for other processes' writes. The database stays open;
// it belongs to whoever opened it.
func (st *Store) Close() {
select {
case <-st.stop:
default:
close(st.stop)
<-st.done
}
}
// Read runs fn against a consistent view of the state. fn must not retain or // Read runs fn against a consistent view of the state. fn must not retain or
// mutate anything it is given. // mutate anything it is given.
func (st *Store) Read(fn func(*State)) { func (st *Store) Read(fn func(*State)) {
@@ -49,11 +76,34 @@ func (st *Store) Read(fn func(*State)) {
fn(st.s) fn(st.s)
} }
// Empty reports whether nothing has ever been stored.
func (st *Store) Empty() bool {
st.mu.RLock()
defer st.mu.RUnlock()
return len(st.rows) == 0
}
// errStale means another process committed since this one loaded.
var errStale = errors.New("store: stale generation")
// Update applies fn to a copy of the state and commits it. If fn or the write // Update applies fn to a copy of the state and commits it. If fn or the write
// fails, the stored state is left untouched. // fails, the stored state is left untouched. fn may run more than once when
// another process writes at the same moment, so it must only change the
// state it is given and the variables it reports results through.
func (st *Store) Update(fn func(*State) error) error { func (st *Store) Update(fn func(*State) error) error {
return st.UpdateWith(fn, nil)
}
// UpdateWith is Update with more writes in the same transaction: inTx runs
// after the generation is locked and before the commit, so what it writes
// lands together with the state or not at all, and no other process can
// write in between. Like fn, it may run more than once.
func (st *Store) UpdateWith(fn func(*State) error, inTx func(context.Context, *sql.Tx) error) error {
st.mu.Lock() st.mu.Lock()
defer st.mu.Unlock() defer st.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for attempt := 0; attempt < 5; attempt++ {
next, err := clone(st.s) next, err := clone(st.s)
if err != nil { if err != nil {
return err return err
@@ -61,12 +111,70 @@ func (st *Store) Update(fn func(*State) error) error {
if err := fn(next); err != nil { if err := fn(next); err != nil {
return err return err
} }
if err := st.persist(next); err != nil { rows, err := st.flatten(next)
if err != nil {
return err return err
} }
digests := make(map[string][32]byte, len(rows))
var upserts []string
for id, plain := range rows {
d := sha256.Sum256(plain)
digests[id] = d
if old, ok := st.rows[id]; !ok || old != d {
upserts = append(upserts, id)
}
}
var deletes []string
for id := range st.rows {
if _, ok := rows[id]; !ok {
deletes = append(deletes, id)
}
}
if len(upserts) == 0 && len(deletes) == 0 && inTx == nil {
st.s = next st.s = next
return nil return nil
} }
err = st.db.Tx(ctx, func(tx *sql.Tx) error {
gen, err := st.db.LockGeneration(ctx, tx)
if err != nil {
return err
}
if gen != st.gen {
return errStale
}
if inTx != nil {
if err := inTx(ctx, tx); err != nil {
return err
}
}
for _, id := range upserts {
if _, err := tx.ExecContext(ctx, st.db.Q(`INSERT INTO keeporg_records (id, body)
VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET body = excluded.body`),
id, st.key.Seal([]byte(id), rows[id])); err != nil {
return err
}
}
for _, id := range deletes {
if _, err := tx.ExecContext(ctx, st.db.Q(`DELETE FROM keeporg_records WHERE id = ?`), id); err != nil {
return err
}
}
return st.db.SetGeneration(ctx, tx, gen+1)
})
if errors.Is(err, errStale) {
if err := st.reloadLocked(ctx); err != nil {
return err
}
continue
}
if err != nil {
return fmt.Errorf("store: %w", err)
}
st.s, st.rows, st.gen = next, digests, st.gen+1
return nil
}
return errors.New("store: the database kept changing under this update; try again")
}
// GC drops expired sessions, device logins and invites. // GC drops expired sessions, device logins and invites.
func (st *Store) GC(now time.Time) error { func (st *Store) GC(now time.Time) error {
@@ -90,12 +198,214 @@ func (st *Store) GC(now time.Time) error {
}) })
} }
func (st *Store) persist(s *State) error { // watch reloads the state when another process has written to the database.
b, err := json.MarshalIndent(s, "", " ") func (st *Store) watch() {
defer close(st.done)
t := time.NewTicker(watchInterval)
defer t.Stop()
for {
select {
case <-st.stop:
return
case <-t.C:
}
ctx, cancel := context.WithTimeout(context.Background(), watchInterval)
gen, err := st.db.Generation(ctx, st.db)
st.mu.RLock()
stale := err == nil && gen != st.gen
st.mu.RUnlock()
if stale {
// A failed reload is retried on the next tick; meanwhile the
// last good state keeps serving.
_ = st.reload(ctx)
}
cancel()
}
}
func (st *Store) reload(ctx context.Context) error {
st.mu.Lock()
defer st.mu.Unlock()
return st.reloadLocked(ctx)
}
// reloadLocked reads every row. The generation and the rows are read in one
// transaction, so they describe the same moment.
func (st *Store) reloadLocked(ctx context.Context) error {
s := newState()
digests := map[string][32]byte{}
var gen int64
err := st.db.Tx(ctx, func(tx *sql.Tx) error {
var err error
if gen, err = st.db.Generation(ctx, tx); err != nil {
return err
}
rows, err := tx.QueryContext(ctx, `SELECT id, body FROM keeporg_records`)
if err != nil { if err != nil {
return err return err
} }
return writeFileAtomic(st.path, b, 0o600) defer rows.Close()
for rows.Next() {
var (
id string
body []byte
)
if err := rows.Scan(&id, &body); err != nil {
return err
}
plain, err := st.key.Open([]byte(id), body)
if err != nil {
return fmt.Errorf("record %s: %w", id, err)
}
if err := s.addRecord(plain); err != nil {
return fmt.Errorf("record %s: %w", id, err)
}
digests[id] = sha256.Sum256(plain)
}
return rows.Err()
})
if err != nil {
return fmt.Errorf("store: load: %w", err)
}
s.sortLists()
st.s, st.rows, st.gen = s, digests, gen
return nil
}
// record is the plaintext of one row: what kind of object, and the object.
type record struct {
Kind string `json:"k"`
Data json.RawMessage `json:"d"`
}
const (
kindUser = "user"
kindGroup = "group"
kindDatabase = "database"
kindInvite = "invite"
kindSession = "session"
kindDevice = "device"
kindMembership = "membership"
kindGrant = "grant"
)
// flatten turns a state into rows. A row's id is a keyed digest of its kind
// and natural key, so ids are stable across writes, and a session token or
// an invite token never appears in the database except inside a sealed row.
func (st *Store) flatten(s *State) (map[string][]byte, error) {
out := map[string][]byte{}
put := func(kind, key string, v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
plain, err := json.Marshal(record{Kind: kind, Data: data})
if err != nil {
return err
}
out[st.key.MAC(kind+"\x00"+key)] = plain
return nil
}
var err error
for k, v := range s.Users {
err = errors.Join(err, put(kindUser, k, v))
}
for k, v := range s.Groups {
err = errors.Join(err, put(kindGroup, k, v))
}
for k, v := range s.Databases {
err = errors.Join(err, put(kindDatabase, k, v))
}
for k, v := range s.Invites {
err = errors.Join(err, put(kindInvite, k, v))
}
for k, v := range s.Sessions {
err = errors.Join(err, put(kindSession, k, v))
}
for k, v := range s.Devices {
err = errors.Join(err, put(kindDevice, k, v))
}
for _, m := range s.Memberships {
err = errors.Join(err, put(kindMembership, m.UserID+"\x00"+m.GroupID, m))
}
for _, g := range s.Grants {
err = errors.Join(err, put(kindGrant, g.GroupID+"\x00"+g.DatabaseID, g))
}
return out, err
}
// addRecord puts one row's object back where it belongs in the state.
func (s *State) addRecord(plain []byte) error {
var r record
if err := json.Unmarshal(plain, &r); err != nil {
return err
}
switch r.Kind {
case kindUser:
v := &User{}
if err := json.Unmarshal(r.Data, v); err != nil {
return err
}
s.Users[v.ID] = v
case kindGroup:
v := &Group{}
if err := json.Unmarshal(r.Data, v); err != nil {
return err
}
s.Groups[v.ID] = v
case kindDatabase:
v := &Database{}
if err := json.Unmarshal(r.Data, v); err != nil {
return err
}
s.Databases[v.ID] = v
case kindInvite:
v := &Invite{}
if err := json.Unmarshal(r.Data, v); err != nil {
return err
}
s.Invites[v.Token] = v
case kindSession:
v := &Session{}
if err := json.Unmarshal(r.Data, v); err != nil {
return err
}
s.Sessions[v.Token] = v
case kindDevice:
v := &DeviceAuth{}
if err := json.Unmarshal(r.Data, v); err != nil {
return err
}
s.Devices[v.DeviceCode] = v
case kindMembership:
var v Membership
if err := json.Unmarshal(r.Data, &v); err != nil {
return err
}
s.Memberships = append(s.Memberships, v)
case kindGrant:
var v Grant
if err := json.Unmarshal(r.Data, &v); err != nil {
return err
}
s.Grants = append(s.Grants, v)
default:
return fmt.Errorf("unknown record kind %q", r.Kind)
}
return nil
}
// sortLists gives the two lists a stable order, since rows come back in
// whatever order the database likes.
func (s *State) sortLists() {
sort.Slice(s.Memberships, func(i, j int) bool {
a, b := s.Memberships[i], s.Memberships[j]
return a.UserID+"\x00"+a.GroupID < b.UserID+"\x00"+b.GroupID
})
sort.Slice(s.Grants, func(i, j int) bool {
a, b := s.Grants[i], s.Grants[j]
return a.GroupID+"\x00"+a.DatabaseID < b.GroupID+"\x00"+b.DatabaseID
})
} }
func clone(s *State) (*State, error) { func clone(s *State) (*State, error) {
@@ -111,26 +421,26 @@ func clone(s *State) (*State, error) {
return out, nil return out, nil
} }
// writeFileAtomic replaces path with data, surviving a crash mid-write. // ReadLegacy reads the state.json document that servers before 0.11 kept,
func writeFileAtomic(path string, data []byte, mode os.FileMode) error { // for importing it into a database.
tmp := path + ".tmp" func ReadLegacy(path string) (*State, error) {
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) b, err := os.ReadFile(path)
if err != nil { if err != nil {
return err return nil, err
} }
if _, err := f.Write(data); err != nil { s := newState()
f.Close() if err := json.Unmarshal(b, s); err != nil {
os.Remove(tmp) return nil, fmt.Errorf("store: parse %s: %w", path, err)
return err
} }
if err := f.Sync(); err != nil { s.init()
f.Close() return s, nil
os.Remove(tmp)
return err
} }
if err := f.Close(); err != nil {
os.Remove(tmp) // Replace overwrites the whole state with s, for an import.
return err func (st *Store) Replace(s *State) error {
} return st.Update(func(next *State) error {
return os.Rename(tmp, path) *next = *s
next.init()
return nil
})
} }
+145 -17
View File
@@ -1,44 +1,128 @@
package store package store
import ( import (
"bytes"
"context"
"errors" "errors"
"os"
"path/filepath"
"testing" "testing"
"time" "time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/sqldb"
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
) )
func TestUpdatePersistsAndSurvivesReopen(t *testing.T) { var testKey, _ = secret.NewKeyring(bytes.Repeat([]byte{7}, 32))
p := filepath.Join(t.TempDir(), "state.json")
st, err := Open(p) // open opens a store on url, closing it when the test ends.
func open(t *testing.T, url string) (*Store, *sqldb.DB) {
t.Helper()
db, err := sqldb.Open(context.Background(), url)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
st, err := Open(context.Background(), db, testKey)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close(); db.Close() })
return st, db
}
func TestUpdatePersistsAndSurvivesReopen(t *testing.T) {
url := sqldbtest.URL(t)
st, _ := open(t, url)
if err := st.Update(func(s *State) error { if err := st.Update(func(s *State) error {
s.Users["u1"] = &User{ID: "u1", Username: "alice"} s.Users["u1"] = &User{ID: "u1", Username: "alice"}
s.Sessions["tok"] = &Session{Token: "tok", UserID: "u1"}
s.Memberships = append(s.Memberships, Membership{UserID: "u1", GroupID: "g", Role: RoleAdmin})
return nil return nil
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
again, err := Open(p) again, _ := open(t, url)
again.Read(func(s *State) {
if s.Users["u1"] == nil || s.Users["u1"].Username != "alice" || s.Sessions["tok"] == nil ||
len(s.Memberships) != 1 || s.Memberships[0].Role != RoleAdmin {
t.Fatalf("reopened state = %+v", s)
}
})
}
func TestRowsAreSealedAndNamedOpaquely(t *testing.T) {
st, db := open(t, sqldbtest.URL(t))
if err := st.Update(func(s *State) error {
s.Users["u1"] = &User{ID: "u1", Username: "alice", Email: "alice@example.com"}
s.Sessions["secret-session-token"] = &Session{Token: "secret-session-token", UserID: "u1"}
return nil
}); err != nil {
t.Fatal(err)
}
rows, err := db.Query(`SELECT id, body FROM keeporg_records`)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var name string defer rows.Close()
again.Read(func(s *State) { name = s.Users["u1"].Username }) n := 0
if name != "alice" { for rows.Next() {
t.Fatalf("reopened username = %q", name) var id string
var body []byte
if err := rows.Scan(&id, &body); err != nil {
t.Fatal(err)
} }
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o600 { for _, plain := range []string{"alice", "example.com", "secret-session-token", "u1", "user"} {
t.Fatalf("state file mode = %v", fi.Mode().Perm()) if bytes.Contains([]byte(id), []byte(plain)) || bytes.Contains(body, []byte(plain)) {
t.Fatalf("row %s carries %q in the clear", id, plain)
}
}
n++
}
if n != 2 {
t.Fatalf("rows = %d, want 2", n)
}
}
func TestOnlyChangedRowsAreWritten(t *testing.T) {
st, db := open(t, sqldbtest.URL(t))
_ = st.Update(func(s *State) error {
s.Users["u1"] = &User{ID: "u1", Username: "alice"}
s.Users["u2"] = &User{ID: "u2", Username: "bob"}
return nil
})
body := func() map[string]string {
out := map[string]string{}
rows, _ := db.Query(`SELECT id, body FROM keeporg_records`)
defer rows.Close()
for rows.Next() {
var id string
var b []byte
rows.Scan(&id, &b)
out[id] = string(b)
}
return out
}
before := body()
gen, _ := db.Generation(context.Background(), db)
_ = st.Update(func(s *State) error { s.Users["u2"].Email = "bob@example.com"; return nil })
after := body()
changed := 0
for id := range before {
if before[id] != after[id] {
changed++
}
}
if changed != 1 {
t.Fatalf("%d rows rewritten for one changed user", changed)
}
// A no-op update writes nothing and does not move the generation.
_ = st.Update(func(s *State) error { return nil })
if g, _ := db.Generation(context.Background(), db); g != gen+1 {
t.Fatalf("generation %d after one write from %d", g, gen)
} }
} }
func TestFailedUpdateLeavesStateUntouched(t *testing.T) { func TestFailedUpdateLeavesStateUntouched(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "state.json")) st, _ := open(t, sqldbtest.URL(t))
if err != nil {
t.Fatal(err)
}
boom := errors.New("boom") boom := errors.New("boom")
if err := st.Update(func(s *State) error { if err := st.Update(func(s *State) error {
s.Users["u1"] = &User{ID: "u1"} s.Users["u1"] = &User{ID: "u1"}
@@ -53,8 +137,52 @@ func TestFailedUpdateLeavesStateUntouched(t *testing.T) {
} }
} }
// Two processes on one database: a write by one is seen by the other, and a
// write based on stale state is re-applied on top of the newer one instead
// of overwriting it.
func TestTwoStoresShareOneDatabase(t *testing.T) {
url := sqldbtest.URL(t)
a, _ := open(t, url)
b, _ := open(t, url)
_ = a.Update(func(s *State) error { s.Users["u1"] = &User{ID: "u1", Username: "alice"}; return nil })
if err := b.Update(func(s *State) error { s.Users["u2"] = &User{ID: "u2", Username: "bob"}; return nil }); err != nil {
t.Fatal(err)
}
b.Read(func(s *State) {
if s.Users["u1"] == nil || s.Users["u2"] == nil {
t.Fatalf("b lost a's write: %v", s.Users)
}
})
// a learns of b's write without writing itself.
deadline := time.Now().Add(3 * watchInterval)
for {
var ok bool
a.Read(func(s *State) { ok = s.Users["u2"] != nil })
if ok {
break
}
if time.Now().After(deadline) {
t.Fatal("a never saw b's write")
}
time.Sleep(50 * time.Millisecond)
}
}
func TestWrongKeyIsRefused(t *testing.T) {
url := sqldbtest.URL(t)
_, db := open(t, url)
if err := db.CheckKey(context.Background(), testKey); err != nil {
t.Fatal(err)
}
other, _ := secret.NewKeyring(bytes.Repeat([]byte{8}, 32))
if err := db.CheckKey(context.Background(), other); !errors.Is(err, sqldb.ErrWrongKey) {
t.Fatalf("a different key was accepted: %v", err)
}
}
func TestGCDropsExpired(t *testing.T) { func TestGCDropsExpired(t *testing.T) {
st, _ := Open(filepath.Join(t.TempDir(), "state.json")) st, _ := open(t, sqldbtest.URL(t))
now := time.Now() now := time.Now()
_ = st.Update(func(s *State) error { _ = st.Update(func(s *State) error {
s.Sessions["old"] = &Session{Token: "old", ExpiresAt: now.Add(-time.Hour)} s.Sessions["old"] = &Session{Token: "old", ExpiresAt: now.Add(-time.Hour)}
+12 -4
View File
@@ -52,21 +52,29 @@ func Code(secret string, t time.Time) (string, error) {
// Check reports whether given is a valid password for secret at time t, // Check reports whether given is a valid password for secret at time t,
// allowing one step of drift either way. // allowing one step of drift either way.
func Check(secret, given string, t time.Time) bool { func Check(secret, given string, t time.Time) bool {
_, ok := Match(secret, given, t)
return ok
}
// Match is Check that also says which time step the code belongs to. A
// caller that remembers the last step it accepted, and refuses that one and
// any before it, makes each code good for one use only.
func Match(secret, given string, t time.Time) (int64, bool) {
key, err := decode(secret) key, err := decode(secret)
if err != nil { if err != nil {
return false return 0, false
} }
given = strings.TrimSpace(given) given = strings.TrimSpace(given)
if len(given) != digits { if len(given) != digits {
return false return 0, false
} }
ctr := uint64(t.Unix()) / uint64(step.Seconds()) ctr := uint64(t.Unix()) / uint64(step.Seconds())
for d := -skew; d <= skew; d++ { for d := -skew; d <= skew; d++ {
if hmac.Equal([]byte(code(key, ctr+uint64(d))), []byte(given)) { if hmac.Equal([]byte(code(key, ctr+uint64(d))), []byte(given)) {
return true return int64(ctr) + int64(d), true
} }
} }
return false return 0, false
} }
func decode(secret string) ([]byte, error) { func decode(secret string) ([]byte, error) {
+13
View File
@@ -1,3 +1,16 @@
keeporg (0.6.0) unstable; urgency=medium
* Security hardening: the tray and CLI open only the server's own
approval page at sign-in; database names that differ in case, or match
a file left by a database you lost access to, no longer share a file;
renames never replace a file; a save made during a pull is never
overwritten; https is required except for this machine
(allow_insecure_http); the event stream sends its token in a header;
bounded reads from the server; escaped notifications; control
characters stripped from terminal output; escaped autostart entries.
-- Azer Abdullaev <github@reactorcoremeltdown.anonaddy.com> Mon, 02 Nov 2026 15:26:52 +0100
keeporg (0.5.0) unstable; urgency=medium keeporg (0.5.0) unstable; urgency=medium
* KeepOrg is now a system tray application: started without a command (or * KeepOrg is now a system tray application: started without a command (or
+33
View File
@@ -1,3 +1,36 @@
keeporgd (0.12.0) unstable; urgency=medium
* Security hardening after an audit. Single sign-on: a device is approved
only by an explicit Approve in the browser that started the sign-in;
the round trip is bound to that browser by a cookie and uses PKCE;
identities are never linked to accounts by username, by verified email
only when link_verified_email is set, never to administrators; existing
accounts link from their Account page. Sign-in: per-address and
per-account attempt limits (429), password hashing outside the store
lock, uniform timing, one-time codes good once, starting two-factor
needs the password. Merge vaults: bounds on KDF parameters, payload
size and XML shape; pushes checked before storing; 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; streams end on revocation.
CSP, frame-ancestors, no-store and HSTS on every response; sign-in form
CSRF. 404 for databases without access. New trusted_proxies /
KEEPORG_TRUSTED_PROXIES.
-- Azer Abdullaev <github@reactorcoremeltdown.anonaddy.com> Mon, 02 Nov 2026 15:26:52 +0100
keeporgd (0.11.0) unstable; urgency=medium
* The server keeps its data in SQLite (the default, a file in the data
directory) or PostgreSQL (database setting, KEEPORG_DATABASE_URL or
-database), every row encrypted with AES-256-GCM under a key derived
from the master key and named by an HMAC, the KDBX versions included.
The master key is now required to start, and a different key is
refused. A data directory from 0.10 or earlier is imported on first
start and its old files set aside. Several servers may share one
PostgreSQL database. -reset-admin no longer needs the server stopped.
-- Azer Abdullaev <github@reactorcoremeltdown.anonaddy.com> Mon, 02 Nov 2026 13:59:24 +0100
keeporgd (0.10.0) unstable; urgency=medium keeporgd (0.10.0) unstable; urgency=medium
* New Account page in the web interface: every signed-in browser and * New Account page in the web interface: every signed-in browser and
+9 -2
View File
@@ -8,8 +8,12 @@
# #
# The key is provisioned as a secret and named to the server with # The key is provisioned as a secret and named to the server with
# KEEPORG_MASTER_KEY_FILE, so it is never in the process environment and # KEEPORG_MASTER_KEY_FILE, so it is never in the process environment and
# never on a disk the container can write. Without a key the server still # never on a disk the container can write. Everything the server stores is
# runs and still synchronizes; it only refuses to create or merge vaults. # encrypted under that key, so it does not start without it.
#
# The data goes to a SQLite file on the keeporg-data volume. For PostgreSQL,
# set KEEPORG_DATABASE_URL below instead; the volume then only matters for
# importing a data directory from 0.10 or earlier.
services: services:
keeporgd: keeporgd:
@@ -36,6 +40,9 @@ services:
- keeporg_master_key - keeporg_master_key
environment: environment:
KEEPORG_MASTER_KEY_FILE: /run/secrets/keeporg_master_key KEEPORG_MASTER_KEY_FILE: /run/secrets/keeporg_master_key
# PostgreSQL instead of the SQLite file. The URL carries a password,
# so in production put it in a secret too.
# KEEPORG_DATABASE_URL: "postgres://keeporg:change-me@postgres:5432/keeporg?sslmode=disable"
# The alternative, when the key comes from a store that injects # The alternative, when the key comes from a store that injects
# variables rather than files. One or the other, not both. # variables rather than files. One or the other, not both.
# KEEPORG_MASTER_KEY: "${KEEPORG_MASTER_KEY:-}" # KEEPORG_MASTER_KEY: "${KEEPORG_MASTER_KEY:-}"
+15 -1
View File
@@ -14,8 +14,12 @@ RestartSec=5
StateDirectory=keeporg StateDirectory=keeporg
StateDirectoryMode=0700 StateDirectoryMode=0700
ConfigurationDirectory=keeporg ConfigurationDirectory=keeporg
ConfigurationDirectoryMode=0750
# The server reads one config file and writes one data directory. # The server reads one config file and the master key, and writes one data
# directory (the SQLite database) or nothing at all (PostgreSQL). The master
# key is created by the package, not by the service, which cannot write /etc.
UMask=0077
NoNewPrivileges=yes NoNewPrivileges=yes
PrivateTmp=yes PrivateTmp=yes
PrivateDevices=yes PrivateDevices=yes
@@ -24,6 +28,14 @@ ProtectHome=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
ProtectControlGroups=yes ProtectControlGroups=yes
ProtectKernelLogs=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RestrictRealtime=yes
RestrictSUIDSGID=yes
RemoveIPC=yes
ReadWritePaths=/var/lib/keeporg ReadWritePaths=/var/lib/keeporg
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes RestrictNamespaces=yes
@@ -31,6 +43,8 @@ LockPersonality=yes
MemoryDenyWriteExecute=yes MemoryDenyWriteExecute=yes
SystemCallArchitectures=native SystemCallArchitectures=native
SystemCallFilter=@system-service SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
SystemCallErrorNumber=EPERM
CapabilityBoundingSet= CapabilityBoundingSet=
AmbientCapabilities= AmbientCapabilities=
+13 -3
View File
@@ -64,14 +64,24 @@ if [ "$1" = configure ]; then
--gecos "KeepOrg synchronization server" keeporg --gecos "KeepOrg synchronization server" keeporg
fi fi
install -d -o keeporg -g keeporg -m 0700 /var/lib/keeporg install -d -o keeporg -g keeporg -m 0700 /var/lib/keeporg
chgrp keeporg /etc/keeporg/server.json || true chgrp keeporg /etc/keeporg /etc/keeporg/server.json || true
chmod 0750 /etc/keeporg || true
chmod 0640 /etc/keeporg/server.json || true chmod 0640 /etc/keeporg/server.json || true
# The service cannot write /etc (ProtectSystem=strict), and without its
# master key the server does not start, so the key is made here, once.
# It is never replaced: a new key opens nothing that is already stored.
if [ ! -e /etc/keeporg/master.key ]; then
(umask 077 && head -c 32 /dev/urandom | base64 > /etc/keeporg/master.key)
chgrp keeporg /etc/keeporg/master.key
chmod 0640 /etc/keeporg/master.key
fi
if [ -d /run/systemd/system ]; then if [ -d /run/systemd/system ]; then
systemctl daemon-reload || true systemctl daemon-reload || true
echo "keeporgd: edit /etc/keeporg/server.json, then" \ echo "keeporgd: edit /etc/keeporg/server.json, then" \
"systemctl enable --now keeporgd" >&2 "systemctl enable --now keeporgd" >&2
echo "keeporgd: a master key will be created at /etc/keeporg/master.key;" \ echo "keeporgd: the master key is /etc/keeporg/master.key; everything" \
"back it up separately from /var/lib/keeporg" >&2 "the server stores is encrypted under it. Back it up separately" \
"from the database: losing it loses the data" >&2
fi fi
fi fi
exit 0 exit 0
+7 -7
View File
@@ -1,13 +1,13 @@
# Single source of truth for all platform versions (semver). # Single source of truth for all platform versions (semver).
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch> # Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
VERSION_SERVER := 0.10.0 VERSION_SERVER := 0.12.0
VERSION_LINUX := 0.5.0 VERSION_LINUX := 0.6.0
VERSION_MACOS := 0.5.0 VERSION_MACOS := 0.6.0
VERSION_WINDOWS := 0.5.0 VERSION_WINDOWS := 0.6.0
VERSION_BSD := 0.5.0 VERSION_BSD := 0.6.0
VERSION_ANDROID := 0.3.0 VERSION_ANDROID := 0.4.0
# Monotonic Android versionCode, never decreases. # Monotonic Android versionCode, never decreases.
ANDROID_VERSION_CODE := 3 ANDROID_VERSION_CODE := 4
# Reverse-DNS application identifiers. # Reverse-DNS application identifiers.
APPID_ANDROID := cc.tiredsysadmin.android.keeporg APPID_ANDROID := cc.tiredsysadmin.android.keeporg