Every Android release build runs lint, which AGP 8.7 cannot run on JDK 26, the JDK Homebrew's Gradle starts on; make android failed unless JAVA_HOME was set by hand. gradle/gradle-daemon-jvm.properties now runs the Gradle daemon on JDK 17 whatever launches it. The Helm values trusted every private address range as a proxy, which let any client on the LAN choose its own address for the sign-in rate limits. They now name only the nginx host and the k3s pod network. The port gains healthcheckPath, so the pod gets readiness and liveness probes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1114 lines
61 KiB
Markdown
1114 lines
61 KiB
Markdown
# KeepOrg: developer handover
|
|
|
|
KeepOrg makes a KeePass database collaborative without changing KeePass. A
|
|
server holds many KDBX files and arbitrates who wrote last; clients present
|
|
each assigned database as an ordinary `.kdbx` file that any KeePass
|
|
application can open, and synchronize it on every save.
|
|
|
|
There are two kinds of database here, and the difference is the whole
|
|
security model:
|
|
|
|
- **A key-less database** is one the server only ever moves. It cannot be
|
|
opened here, so a compromised server leaks ciphertext and the group
|
|
structure and nothing else. Conflicts are settled by picking a winner.
|
|
- **A merge vault** is one the server can open, because it keeps that
|
|
vault's password. That is the price of folding two people's edits
|
|
together: merging means decrypting. Whoever controls the server and its
|
|
master key can read every merge vault on it.
|
|
|
|
Both are first-class. A deployment that wants no server-side key uses
|
|
`server-wins`, `client-wins` or `manual` and loses nothing but the merge. A
|
|
deployment that wants merging accepts that the server is now part of its
|
|
trust boundary, and should treat the host accordingly.
|
|
|
|
## Repository map
|
|
|
|
```
|
|
cmd/keeporgd/ server entry point: flags, config file, bootstrap
|
|
cmd/keeporg/ desktop client: the tray application (agent.go is its
|
|
logic, tray.go its menu), login, run, sync, status,
|
|
administration, start-at-login per platform
|
|
internal/store/ domain model, the in-memory state and its sealed rows,
|
|
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/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web,
|
|
web administration, page templates
|
|
internal/client/ shared client engine: API wrapper, merge policy, mirror,
|
|
configuration, the mirror directory lock
|
|
internal/icon/ the padlock, drawn in code: tray PNG/ICO, app icons
|
|
internal/secret/ password verifiers, tokens, identifiers
|
|
internal/totp/ RFC 6238 second factor
|
|
android/ Android client: DocumentsProvider, sync service, UI
|
|
Dockerfile the server's container image, built from scratch
|
|
packaging/ deb, macOS bundle and DMG, Windows zip, systemd units,
|
|
container compose example
|
|
scripts/ package builders, version bump, changelog
|
|
tools/genicon/ writes the Linux icon theme and the macOS iconset
|
|
versions.mk the one place every platform's version lives
|
|
Makefile the only entry point for building and publishing
|
|
```
|
|
|
|
## The three programs
|
|
|
|
### Server (`keeporgd`)
|
|
|
|
One process, one database, one master key:
|
|
|
|
- **The database** is SQLite (`sqlite:<path>`, by default
|
|
`<data_dir>/keeporg.db`) or PostgreSQL (`postgres://…`), chosen by the
|
|
`database` setting, `KEEPORG_DATABASE_URL` or `-database`. It holds
|
|
everything: users, groups, memberships, databases, grants, invites,
|
|
sessions, pending device logins, and the last 20 versions of every KDBX
|
|
payload.
|
|
- **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` keeps the metadata in memory as one `State` and is
|
|
the only writer of it. Every mutation runs through `Update(func(*State)
|
|
error)`, which applies the change to a private copy, writes the rows that
|
|
changed in one transaction, and only then publishes the copy. A failed
|
|
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
|
|
pure functions over `*State` in `internal/store/acl.go`, which is why they
|
|
are cheap enough to call per request and easy to test exhaustively.
|
|
|
|
### Desktop client (`keeporg`)
|
|
|
|
One binary, two faces. Started without a command it is a system tray
|
|
application; with a command it is the CLI. Both drive the same mirror, which
|
|
keeps a directory (default `~/KeepOrg`) in step with the server:
|
|
|
|
- a rescan every two seconds notices that a KeePass application saved a file;
|
|
- the server's event stream delivers other people's pushes immediately;
|
|
- `.keeporg-state.json` in the mirror records what this device last agreed
|
|
with the server for each database.
|
|
|
|
All of the synchronization policy is the pure `decide()` table in
|
|
`internal/client/decide.go`. Everything else in the mirror applies the action
|
|
it returns. To change behaviour, change that table and its test.
|
|
|
|
**One agent per directory.** `Mirror.Run` takes an OS lock on
|
|
`.keeporg.lock` in the mirror directory (`flock`, or an unshared handle on
|
|
Windows) and holds it until it stops; `keeporg sync` takes it too. Two agents
|
|
on one directory would each take the other's writes for local saves and
|
|
push them back, and with a tray, a systemd unit and a Task Scheduler job all
|
|
available that is easy to set up by accident. The OS drops the lock when
|
|
the process dies, so a crash never leaves a directory locked.
|
|
|
|
#### The tray
|
|
|
|
The tray is split so that nothing that decides anything needs a desktop:
|
|
|
|
- `agent.go` owns the configuration, the mirror's lifecycle, sign-in and
|
|
sign-out, and turns the agent's state into a `view` — the status line, the
|
|
database list, which items are enabled — in one pure function,
|
|
`buildView`. `statusNotice` decides which state changes are worth a
|
|
notification: conflicts, kept copies, merges, failures; never a routine
|
|
pull. Both are table-tested, and `agent_server_test.go` drives the agent
|
|
against a real in-process server through a sync and a server-side
|
|
revocation.
|
|
- `tray.go` is the menu: it builds the items once, and once a second calls
|
|
`agent.tick()` and applies whatever changed in the view. The database
|
|
submenu is 32 pre-made items that are retitled, shown and hidden, because
|
|
not every tray host copes with items being removed.
|
|
|
|
Behaviour worth knowing:
|
|
|
|
- **Sign-in is the device flow** the CLI's `-browser` option uses: the tray
|
|
opens the approval page and polls. It covers password, second factor and
|
|
single sign-on with no dialog in the tray itself. With no `server_url`
|
|
configured, Sign In opens the configuration file instead.
|
|
- **The configuration is the settings interface.** The first start writes
|
|
`config.json` with every key. A tick notices a saved edit by its
|
|
modification time; a file that no longer parses is reported once and the
|
|
running configuration kept; the mirror restarts only when a setting it was
|
|
built from changed (`mirrorSettingsChanged`). The tray's own writes record
|
|
the new modification time, so signing in does not look like an edit.
|
|
- **A revoked session signs the device out.** When the server answers
|
|
`unauthorized`, the agent stops, clears the token from the file and says
|
|
so, rather than retrying every two seconds forever.
|
|
- **The mirror's goroutine never takes the agent's lock.** Stopping the
|
|
mirror holds that lock while it waits for the goroutine; the goroutine
|
|
reports how it ended through its own small `runErr` instead.
|
|
- **SIGTERM quits** the same way the menu does, which matters at logout.
|
|
- **Start at Login** writes a LaunchAgent (`<AppID>.plist`) on macOS, an XDG
|
|
autostart entry on Linux and the BSDs, and the `HKCU\…\Run` value on
|
|
Windows, each running `keeporg tray` from the resolved executable path.
|
|
- **Platform specifics.** macOS gets a template icon, so the menu bar
|
|
recolours it; Windows gets an ICO. Notifications use `osascript` and
|
|
`notify-send`; Windows has none yet, and the status line carries the same
|
|
news. The About box is AppleScript, zenity or kdialog, or a Windows
|
|
message box.
|
|
|
|
Every client setting, with its default, is in the README's configuration
|
|
table and in the comments on `client.Config`. `LoadConfig` reads a file over
|
|
a fully defaulted `Config`, so a key a file leaves out keeps its default —
|
|
that is how an 0.4 file with no `notifications` key still means `true`.
|
|
|
|
### Android client
|
|
|
|
`KeepOrgProvider` is a `DocumentsProvider`, so the mirrored files appear in
|
|
every file picker under "KeepOrg" and KeePassDX or Keepass2Android opens them
|
|
as ordinary documents. When such an application closes a file it had open for
|
|
writing, the provider triggers a synchronization pass; that is how a save
|
|
reaches the server. `SyncService` is a foreground service that holds the
|
|
event stream open so colleagues' changes arrive without opening the app.
|
|
|
|
`Mirror.decide` is a transcription of the Go decision table. **Keep the two in
|
|
step**: a change to one belongs in the other in the same commit.
|
|
|
|
**Browser sign-in survives the app being stopped.** On a phone the browser
|
|
is a separate application, and Android may kill KeepOrg's process while it
|
|
is in front — SSO with a second factor is plenty of time for that. So the
|
|
pending device code is kept in `Prefs.pendingSignIn`, not in the activity,
|
|
and `LoginActivity` collects it on every `onResume`, polling at once. The
|
|
server's approval page links back with `keeporg://device-approved`, which
|
|
the device passes as `return_url` and the manifest routes to
|
|
`LoginActivity` (`singleTask`, so the waiting screen comes back rather than
|
|
a new one stacking on the browser). The link carries no data: it only
|
|
brings the screen forward, and the session still arrives by polling. A
|
|
session collected while the screen is in the background is saved at once
|
|
and the main screen opened on the next resume, because Android forbids
|
|
starting an activity from the background. Against a server older than 0.9,
|
|
which rejects `return_url`, `Api.deviceStart` retries without it and the
|
|
user returns by hand.
|
|
|
|
Before this, the code lived in a thread inside the activity: a killed
|
|
process lost it, the approval reached the server and nobody collected it,
|
|
and the app came back signed out with nothing saved.
|
|
|
|
### The KDBX codec (`internal/kdbx`)
|
|
|
|
A real KeePass 2 implementation, because merging cannot be done on
|
|
guesswork. It reads and writes KDBX 4.0 and 4.1 with AES-256-CBC or ChaCha20
|
|
payloads, AES-KDF or Argon2d/Argon2id derivation, and the ChaCha20 inner
|
|
stream that protects individual values. ChaCha20, BLAKE2b and Argon2 are
|
|
implemented in the package: the standard library has none of them, and this
|
|
repository carries no third-party dependencies. Each is checked against its
|
|
RFC's test vectors.
|
|
|
|
Two decisions in there are worth keeping:
|
|
|
|
**The XML payload is a generic node tree, not Go structs.** A merge server
|
|
that dropped elements it did not model would silently destroy data written by
|
|
a newer KeePass. Everything unrecognised round-trips untouched, and the merge
|
|
logic reaches only into the paths it understands: UUID, Times, String,
|
|
History, Binary, DeletedObjects, Meta.
|
|
|
|
**Anything unsupported is reported, never guessed.** A KDBX 3 file, a Twofish
|
|
payload or a Salsa20 inner stream comes back as `*kdbx.ErrUnsupported`, and
|
|
the server then falls back to a non-merging strategy rather than mangling the
|
|
file.
|
|
|
|
The format is verified against the real thing: the test suite has
|
|
keepassxc-cli read, decrypt and re-save vaults this code writes, and has this
|
|
code read what KeePassXC saved.
|
|
|
|
## Access control
|
|
|
|
Groups form one tree. Two rules decide everything:
|
|
|
|
1. **A grant on a group reaches every member below it.** A grant on
|
|
`Company` applies to a member of `Company/Engineering/Backend`, because
|
|
the member's effective group set is their own groups plus all ancestors.
|
|
2. **An administrator of a group administers everything below it.** A
|
|
`Company` admin administers `Backend`; a `Sales` admin does not.
|
|
|
|
A database is owned by one group. Administering that group means `admin` on
|
|
the database: renaming it, changing its merge strategy, granting other groups
|
|
access and deleting it. Other groups reach it only through explicit grants,
|
|
with `read`, `write` or `admin`. A user's permission on a database is the
|
|
strongest grant that reaches them. Server administrators administer
|
|
everything.
|
|
|
|
Nothing is browsable. A user who has no permission on a database cannot see
|
|
that it exists: the blob endpoint answers `404`, not `403`.
|
|
|
|
## Synchronization protocol
|
|
|
|
Full endpoint reference: [PROTOCOL.md](PROTOCOL.md). The part that matters:
|
|
|
|
```
|
|
GET /api/v1/databases what is assigned, with version and digest
|
|
GET /api/v1/databases/{id}/blob bytes, ETag: "<version>"
|
|
PUT /api/v1/databases/{id}/blob If-Match: "<version this edit was based on>"
|
|
GET /api/v1/events SSE stream of db-updated notifications
|
|
```
|
|
|
|
A push always states the version it was based on. If that is still current,
|
|
the server stores version+1, publishes `db-updated` to everyone who can read
|
|
the database, and prunes old versions. If it is not current, the stored merge
|
|
strategy decides, and the rejection carries both the current version and the
|
|
strategy so the client can resolve without another round trip.
|
|
|
|
Pushing bytes the server already holds is a no-op that answers
|
|
`{"unchanged": true}`: a save that changed nothing does not burn a version
|
|
and does not wake other clients.
|
|
|
|
### Merge strategies
|
|
|
|
Set per database, changed by an administrator of the owning group
|
|
(`keeporg db -id <id> -strategy <name>`).
|
|
|
|
| Strategy | Server behaviour on a stale push | Client behaviour |
|
|
| --- | --- | --- |
|
|
| `merge` | opens both copies in memory, folds them together, stores the result as a new version | pushes as usual, then pulls the merged result |
|
|
| `server-wins` | rejects with `409 conflict` | keeps the local save as `<name>.conflict-<timestamp>.kdbx`, then writes the server copy |
|
|
| `client-wins` | accepts a retry carrying `If-Match: *` | retries with force; the server copy is overwritten and kept in history |
|
|
| `manual` | rejects, and refuses force | writes the server copy as `<name>.server-v<n>.kdbx` beside the file, touches nothing, reports a conflict once |
|
|
|
|
`merge` is the default for a vault created through the web interface;
|
|
`server-wins` is the default for a database created without a password,
|
|
because the server has no key for it.
|
|
|
|
A losing save is never discarded: it is always written somewhere the user can
|
|
find it. A read-only user who edits their copy gets the same treatment, with
|
|
the server copy restored.
|
|
|
|
### What merging actually does
|
|
|
|
`kdbx.Merge` follows KeePass's own synchronization rules. Objects are matched
|
|
by UUID across the two copies:
|
|
|
|
- An object only one side has is added, unless the other side deleted it
|
|
more recently than it was last modified.
|
|
- An object both sides changed is resolved by `Times/LastModificationTime`.
|
|
The newer version wins and **the loser is filed in the entry's history**,
|
|
so a merge never destroys an edit, it demotes one.
|
|
- A deletion applies only when it is newer than the other side's edit.
|
|
Otherwise the edit resurrects the object and the stale deletion record is
|
|
dropped.
|
|
- Groups keep their nesting; an object moves when the other side moved it
|
|
more recently, by `Times/LocationChanged`.
|
|
- Database settings follow `Meta/SettingsChanged`; custom data and icons are
|
|
unioned by key.
|
|
- Attachments are remapped from the incoming pool into the target's. An entry
|
|
that kept its old index would silently gain the wrong file.
|
|
|
|
The ordering of objects inside a group is not merged, and neither does
|
|
KeePass reconcile it.
|
|
|
|
Where it runs: `internal/server/merge.go` decrypts the stored version and the
|
|
pushed one, merges, re-encrypts, and stores the result as a new version. The
|
|
plaintext exists only as two `kdbx.Database` values in memory for the
|
|
duration of one request; nothing is ever written to disk in the clear. If any
|
|
step fails — most often because a client changed the vault's own password, so
|
|
the stored key no longer opens it — the push is answered with
|
|
`409 merge_failed` and the client falls back to keeping its copy aside. A
|
|
failed merge never loses an edit.
|
|
|
|
**The cost.** A merge runs the vault's key derivation three times: the stored
|
|
version, the pushed version, and the re-encryption, each with a different
|
|
salt. At the default 32 MiB / 2 iterations that is roughly 150 ms and 100 MB
|
|
of transient memory per merging push. `vault_kdf` in the configuration is
|
|
the dial; a transformed-key cache would remove most of it and has not been
|
|
written.
|
|
|
|
## Key custody and encryption at rest
|
|
|
|
Three different things are encrypted here, and they are worth keeping apart:
|
|
|
|
1. **The vault contents.** Always encrypted, by KDBX itself: AES-256 under a
|
|
key derived with Argon2d from the vault password. This is true on the
|
|
server's disk, on every client's disk and on the wire. The server stores
|
|
exactly the file a KeePass application would.
|
|
2. **The vault password**, for merge vaults only. Encrypted with AES-256-GCM
|
|
under the server's master key, with the vault's own id as additional
|
|
authenticated data, so a wrapped secret moved to another vault's record
|
|
fails to open instead of unlocking the wrong database. It is never
|
|
returned by the API, never logged, and never leaves the process.
|
|
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:
|
|
|
|
| Source | Shape | Created if absent |
|
|
| --- | --- | --- |
|
|
| `$KEEPORG_MASTER_KEY` | base64 | no |
|
|
| `$KEEPORG_MASTER_KEY_FILE` | a path to a provisioned secret: base64, or 32 raw bytes | no |
|
|
| `master_key_file` / `-master-key-file`, default `/etc/keeporg/master.key` | base64 | yes, mode 0600 |
|
|
|
|
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
|
|
that something else owns, so a missing one is reported rather than replaced:
|
|
minting a key in its place would open nothing that is already stored. The
|
|
server refuses to start without a key, and refuses one that is not the key
|
|
the database was written with (`sqldb.CheckKey` opens a sealed marker stored
|
|
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:
|
|
|
|
- A database dump, a backup, a replica or a curious database administrator
|
|
gets ciphertext. So does a stolen copy of the data directory.
|
|
- The database **and** the master key together open everything, including
|
|
every merge vault. Keep them on different media, or feed the key in
|
|
through `KEEPORG_MASTER_KEY_FILE` from a secret manager, a Kubernetes
|
|
secret or a systemd credential so it never sits beside the data.
|
|
- Backups must cover both, separately, and a database backup without its
|
|
key restores nothing. Losing the key loses the server.
|
|
|
|
## Authentication
|
|
|
|
Three ways in, one kind of credential out: an opaque bearer token, stored
|
|
sealed in the database with an expiry (90 days by default).
|
|
|
|
- **Password** (`POST /api/v1/auth/login`), with the built-in TOTP second
|
|
factor when the account has enrolled one. The server answers
|
|
`totp_required` so a client can ask for the code without guessing.
|
|
- **Invite link.** A group administrator creates one; the invitee opens it in
|
|
a browser, picks a username and password, and lands in the invite's group
|
|
with the invite's role.
|
|
- **Browser-approved device login**, which is also how OIDC works. The client
|
|
gets a short user code, the user approves it in a browser with a password
|
|
or with single sign-on, and the client polls until a token appears. The
|
|
same flow serves a headless desktop and an Android phone.
|
|
|
|
OIDC is implemented against the discovery document with the standard library
|
|
only. The server is a confidential client: it exchanges the authorization
|
|
code over TLS with the token endpoint and reads the identity from the
|
|
userinfo endpoint, so there is no local JWT verification to get wrong.
|
|
|
|
### 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
|
|
|
|
At the identity provider, register a confidential client with one redirect
|
|
URI, which is `external_url` plus `/oidc/callback`:
|
|
|
|
```
|
|
https://keeporg.example.com/oidc/callback
|
|
```
|
|
|
|
Then put the issuer and the client credentials in `/etc/keeporg/server.json`
|
|
and restart:
|
|
|
|
```json
|
|
"oidc": {
|
|
"issuer": "https://id.example.com/realms/main",
|
|
"client_id": "keeporg",
|
|
"client_secret": "...",
|
|
"scopes": "openid profile email",
|
|
"auto_create": false,
|
|
"default_group_id": "",
|
|
"link_verified_email": false
|
|
}
|
|
```
|
|
|
|
`issuer` is the base URL that answers at
|
|
`<issuer>/.well-known/openid-configuration`; everything else is read from
|
|
there. All four fields above the blank ones are required — with any of
|
|
`issuer`, `client_id` or `client_secret` missing, single sign-on is simply
|
|
not offered.
|
|
|
|
Two entry points use it, and both end at the same callback:
|
|
|
|
- **A browser**, from the link on `/web`. `/oidc/login` with no parameters
|
|
starts a sign-in that ends in a session cookie.
|
|
- **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
|
|
time as `/oidc/login?code=<user code>`. After the provider, the person is
|
|
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:
|
|
|
|
1. If some account is already linked to this issuer and `sub`, that one.
|
|
2. Otherwise, only when `link_verified_email` is set and the provider says
|
|
`email_verified`, an unlinked local account with the same email — never a
|
|
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
|
|
in `default_group_id` when one is given. Without it, sign-in is refused.
|
|
|
|
An existing account moves to single sign-on from its own **Account** page:
|
|
**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
|
|
and cannot set one through the password form.
|
|
|
|
The whole flow is covered by tests against a stub provider in
|
|
`internal/server/oidc_test.go`, which is the quickest way to see the
|
|
expected request sequence.
|
|
|
|
## The web interface
|
|
|
|
`/web` is the browsable half of the server. It covers what a command line
|
|
does badly — creating a vault and setting its password, and the daily
|
|
administration of people — and nothing else. It never shows a vault's
|
|
contents: the server opens a vault only to merge it, and there is no entry
|
|
browsing, by design.
|
|
|
|
| Page | What it does |
|
|
| --- | --- |
|
|
| `/web/vaults` | the vaults you can reach, the create-a-vault form |
|
|
| `/web/account` | everyone's own settings: account details, every signed-in browser and client with a button to sign each out (or all but this one), two-factor setup, replacement and removal, the password |
|
|
| `/web/vaults/{id}/delete` | what deleting a vault destroys, and the password that confirms it |
|
|
| `/web/groups` | the group tree you can see, and a form to add a group inside one you administer |
|
|
| `/web/groups/{id}` | one group: members and their roles, the vaults it reaches and where that access comes from, grants given and revoked, deletion |
|
|
| `/web/users` | the accounts you may see; for a server administrator, disable, enable, clear the second factor, sign out everywhere, promote, demote, delete |
|
|
| `/web/invites` | outstanding invite links with their use counts and expiry, a form to make one, a button to revoke one |
|
|
|
|
The navigation offers the last three only to somebody who administers a
|
|
group; a plain member sees their vaults and nothing else. The group selector
|
|
only offers groups the signed-in user administers, and a user who
|
|
administers none is told so instead of being shown a form that would fail.
|
|
|
|
**One set of rules behind both interfaces.** Every administrative change is
|
|
a function in `internal/server/admin.go` taking the state and the caller —
|
|
`createGroup`, `setMember`, `setGrant`, `createInvite`, `revokeInvite`,
|
|
`adminAccount` and the rest. The JSON handlers and the HTML forms are both
|
|
thin wrappers over them. Had the pages re-implemented the checks, the two
|
|
interfaces would have drifted apart on the first change, and that drift would
|
|
have been a permission bug rather than a cosmetic one.
|
|
|
|
**Who may do what.** Group administration follows the tree: administering a
|
|
group means administering everything inside it, so the member and grant forms
|
|
appear only on groups you administer, and their selectors only offer vaults
|
|
you administer. Account changes are deliberately not part of that. They are
|
|
server administrator only: a group administrator decides who reaches which
|
|
vault, which is access, while disabling an account, clearing somebody's
|
|
second factor or deleting them reaches past the group they happen to share.
|
|
The three changes that would lock the caller out — disable, demote, delete on
|
|
their own account — are refused outright, because a server with no reachable
|
|
administrator can only be repaired with `keeporgd -reset-admin` on the host.
|
|
|
|
Browser sessions are the same session tokens the API uses, carried in a
|
|
cookie that is `HttpOnly`, `SameSite=Strict`, `Path=/`, and `Secure` whenever
|
|
`external_url` is https. Every page is served through `webPage` and every
|
|
form through `webForm`: they resolve the cookie, and a form additionally has
|
|
its per-session CSRF token checked before the handler runs. A new form cannot
|
|
forget that check, because the check is not in the handler.
|
|
|
|
No page carries any JavaScript. Each control is its own small form with the
|
|
CSRF token and one action in it, which is what makes the interface work in a
|
|
locked-down browser and keeps its attack surface down to that token.
|
|
|
|
**The account page.** `/web/account`, offered to everyone, is where a
|
|
person manages how they sign in. The rules are in `account.go`, shared with
|
|
the `/api/v1/auth/sessions` and `/api/v1/auth/totp` endpoints:
|
|
|
|
- *Signed-in devices* lists every live session — browsers, named from their
|
|
User-Agent at sign-in ("Firefox on macOS"), and clients, named by the
|
|
label they sent — with when each was last used. Any of them can be signed
|
|
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.
|
|
`LastSeenAt` is refreshed by `markSeen` at most every five minutes, because
|
|
each refresh is a database write.
|
|
- *Two-factor authentication* turns on with nothing more than the session,
|
|
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
|
|
factor on any live session, so a borrowed laptop could quietly disarm it.
|
|
- *Password* moved here from the vault page; changing it still signs out the
|
|
other sessions. An account that signs in only through single sign-on sees
|
|
neither form, and is told that its provider manages both.
|
|
|
|
A server administrator also has *Sign out everywhere* on `/web/users`, for a
|
|
colleague's lost phone: every session of that account goes, and the account
|
|
stays usable.
|
|
|
|
**Deleting a vault asks for the password again.** Every other control on
|
|
these pages is undoable or additive; this one erases every stored version of
|
|
a file. A live session is what an unlocked laptop leaves lying around, so the
|
|
confirmation page states what is about to go — owner, versions, grants — and
|
|
takes the administrator's own account password, plus their one-time code when
|
|
they have one. The same rule covers the API and `keeporg db -delete`, because
|
|
it lives in `confirmDeletion` rather than in the page.
|
|
|
|
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 vault's exact name. That is a weaker
|
|
gate, and it is deliberately the fallback rather than the rule.
|
|
|
|
Deleting takes the record, the grants pointing at it, every stored version
|
|
and — for a merge vault — the key the server held. It does not touch the
|
|
copies in people's mirror directories: the server stops tracking the vault, it
|
|
does not reach into anybody's home directory. Clients are told over SSE, so
|
|
they stop expecting the file rather than discovering it at the next save.
|
|
|
|
Creating a vault is one transaction: the server builds an empty KDBX 4
|
|
database, encrypts it with the password from the form, stores it as version
|
|
1, and — for a merge vault — wraps the password under the master key. If the
|
|
state update fails, the blob is removed again, so a half-created vault cannot
|
|
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
|
|
|
|
**No cgo, and as few dependencies as the job allows.** The server builds
|
|
with `CGO_ENABLED=0`: SSE instead of a WebSocket library, `crypto/pbkdf2`
|
|
for account passwords, hand-written ChaCha20, BLAKE2b and Argon2 for the
|
|
KDBX format, 60 lines of HMAC for TOTP, `html/template` for the pages. Its
|
|
only third-party code is the two database drivers, both pure Go:
|
|
`modernc.org/sqlite`, SQLite translated to Go, and `jackc/pgx` for
|
|
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
|
|
`godbus/dbus` and `golang.org/x/sys` under it), because a tray icon cannot
|
|
be had otherwise for less than several thousand lines: Linux and the BSDs
|
|
speak StatusNotifierItem over D-Bus, Windows needs Win32, and the macOS menu
|
|
bar is reachable only through Cocoa. It was chosen over
|
|
`getlantern/systray`, which `midi-webhook` uses, because it is maintained and
|
|
needs no cgo or GTK on Linux: Linux, BSD and Windows clients still
|
|
cross-compile from anywhere. Only macOS needs cgo, so the Makefile links the
|
|
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
|
|
ciphertext, so the trade is in the program that never sees a key.
|
|
|
|
**A database, held in memory, sealed row by row.** Until 0.11 the server
|
|
kept one JSON document and a directory of KDBX files. Enterprise use needs a
|
|
real database — managed backups, replication, a PostgreSQL the operations
|
|
team already runs — and data that is encrypted at rest under a key the
|
|
database never sees. Three choices follow from that:
|
|
|
|
- *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
|
|
application sees a regular `.kdbx` file. A real file in a real directory is
|
|
that, on Linux, macOS, Windows and BSD alike, with no kernel module, no
|
|
WinFsp or macFUSE dependency, no cgo and nothing to break on an OS upgrade.
|
|
Local writes are atomic (`write` + `rename`), so an application never reads
|
|
half a database. The price is that synchronization happens on a save rather
|
|
than on a `write()` syscall, which is exactly the granularity KDBX has
|
|
anyway.
|
|
|
|
**SSE instead of WebSocket.** The channel only carries server-to-client
|
|
notifications. SSE is a `GET` that survives proxies, reconnects by itself and
|
|
needs no library on any of the three clients. A subscriber that falls behind
|
|
is dropped rather than waited for; it reconnects and reconciles from scratch,
|
|
because every notification only ever triggers a full reconcile.
|
|
|
|
**Versions, not just a digest.** Keeping the last 20 blobs makes a rejected
|
|
push recoverable, makes "who broke the database" answerable, and costs a few
|
|
megabytes.
|
|
|
|
## Operating it
|
|
|
|
```sh
|
|
make deb # keeporg and keeporgd packages for this arch
|
|
sudo dpkg -i dist/keeporgd_*.deb
|
|
sudo nano /etc/keeporg/server.json
|
|
sudo systemctl enable --now keeporgd
|
|
sudo journalctl -u keeporgd # the first administrator's password is logged once
|
|
```
|
|
|
|
### The first sign-in
|
|
|
|
On first start with an empty state the server creates the `admin` account
|
|
and an `everyone` group, and **logs the generated password once**:
|
|
|
|
```
|
|
keeporgd: created the first administrator: username "admin" password "13e4…"
|
|
(shown once, change it or enrol TOTP now)
|
|
```
|
|
|
|
Read it with `journalctl -u keeporgd`, or `podman logs <container>`. It is
|
|
never written anywhere else: the database holds only a PBKDF2 verifier.
|
|
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
|
|
no accounts at all, so leaving it set afterwards does nothing.
|
|
|
|
Change it once you are in, with `keeporg passwd`, or the form at the bottom
|
|
of `/web/vaults`. A change requires the current password, and the one-time
|
|
code when a second factor is enrolled, and signs out that account's other
|
|
sessions.
|
|
|
|
**If the password was lost**, stop the server and run:
|
|
|
|
```sh
|
|
sudo -u keeporg keeporgd -data /var/lib/keeporg -reset-admin
|
|
```
|
|
|
|
It prints a new one, clears any second factor on that account, and revokes
|
|
its sessions. It needs the same `database` and master key as the server
|
|
(pass `-config /etc/keeporg/server.json`, or the same environment), and the
|
|
server may keep running: it writes to the database like any server process,
|
|
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`:
|
|
|
|
```json
|
|
{
|
|
"addr": ":8765",
|
|
"data_dir": "/var/lib/keeporg",
|
|
"database": "sqlite:/var/lib/keeporg/keeporg.db",
|
|
"external_url": "https://keeporg.example.com",
|
|
"session_ttl_hours": 2160,
|
|
"max_db_size_mb": 32,
|
|
"master_key_file": "/etc/keeporg/master.key",
|
|
"vault_kdf": { "memory_mb": 32, "iterations": 2, "lanes": 2 },
|
|
"oidc": {
|
|
"issuer": "https://id.example.com/realms/main",
|
|
"client_id": "keeporg",
|
|
"client_secret": "...",
|
|
"scopes": "openid profile email",
|
|
"auto_create": false,
|
|
"default_group_id": ""
|
|
}
|
|
}
|
|
```
|
|
|
|
`external_url` must be what a browser sees: invite links, device approval
|
|
pages and the OIDC redirect URI are built from it. `KEEPORG_EXTERNAL_URL`
|
|
sets it too, for a deployment that knows the address only at deploy time. Terminate TLS in front of
|
|
the server, or set `tls_cert` and `tls_key`.
|
|
|
|
### Environment variables
|
|
|
|
The server reads six variables of its own: where its database and its
|
|
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
|
|
is no environment equivalent for those. An empty or whitespace-only value
|
|
counts as unset.
|
|
|
|
| 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 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 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_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. |
|
|
|
|
With neither master-key variable set, the key comes from `master_key_file`
|
|
(default `/etc/keeporg/master.key`), which *is* created if missing. See
|
|
[Key custody](#key-custody-and-encryption-at-rest) for why the three sources
|
|
behave differently.
|
|
|
|
The server also honours the Go runtime's standard variables, which matter
|
|
only for its outbound calls to an OIDC provider:
|
|
|
|
| Variable | Effect |
|
|
| --- | --- |
|
|
| `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY` | proxy for the discovery, token and userinfo requests |
|
|
| `SSL_CERT_FILE`, `SSL_CERT_DIR` | CA bundle to trust instead of the system's (Linux and BSD), for an identity provider behind a private CA; in the container the bundle is `/etc/ssl/certs/ca-certificates.crt` |
|
|
|
|
`packaging/docker/compose.yaml` additionally uses `KEEPORG_VERSION` to pick
|
|
the image tag and build argument. That is read by compose, not by the
|
|
server.
|
|
|
|
In the deployment under `config/variables/server.values.yaml`, the master
|
|
key arrives as the Kubernetes secret `keeporg-encryption` mounted on
|
|
`/etc/keeporg`, and `KEEPORG_MASTER_KEY_FILE=/etc/keeporg/config.txt` points
|
|
at it. `KEEPORG_EXTERNAL_URL=https://keeporg.tiredsysadmin.cc` matches the
|
|
nginx `server_name`; the image's command passes neither `-config` nor
|
|
`-url`, so this variable is the only thing that sets it there.
|
|
`KEEPORG_TRUSTED_PROXIES` names only the nginx host (10.200.1.3) and the k3s
|
|
pod network (10.42.0.0/16, the source address after kube-proxy's NodePort
|
|
translation); a wider range would let clients on it choose their own address
|
|
for the rate limits. The port's `healthcheckPath: /healthz` gives the pod
|
|
readiness and liveness probes. The `keeporg-encryption` secret's
|
|
`config.txt` must be the master key itself — base64 of 32 bytes, or 32 raw
|
|
bytes — since 0.11 refuses to start without one.
|
|
`KEEPORG_ADMIN_PASSWORD` is not set there, so the first start logs a
|
|
generated password once.
|
|
|
|
Back up the database **and** `/etc/keeporg/master.key`, separately. The
|
|
database is the whole server: with SQLite that is `keeporg.db` (copy it with
|
|
`sqlite3 keeporg.db ".backup …"` while the server runs, or stop it and copy
|
|
the file with its `-wal`); with PostgreSQL, whatever backs that database up
|
|
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
|
|
|
|
```sh
|
|
keeporg login -server https://keeporg.example.com -user admin
|
|
keeporg group -create engineering -parent <everyone-id>
|
|
keeporg vault -create "Engineering" -group <engineering-id> # prompts for the vault password
|
|
keeporg invite -group <engineering-id> -role member -uses 5 -hours 72
|
|
keeporg grant -group <readers-id> -db <db-id> -perm read
|
|
```
|
|
|
|
Or do all of it at `https://keeporg.example.com/web`: `/web/groups` builds
|
|
the tree and fills it, `/web/invites` issues the links, `/web/vaults` creates
|
|
the vault. The pages call the same functions these commands call.
|
|
|
|
`keeporg vault` creates the KDBX file itself, so the vault has content from
|
|
the start: it appears in every member's mirror directory and opens with the
|
|
password that was set. `keeporg db -create` is the other path — it registers
|
|
a name with no bytes, and the first client to save a KDBX of that name in its
|
|
mirror directory adopts it as version 1. A database created that way has no
|
|
key on the server, so it cannot merge until one is stored:
|
|
|
|
```sh
|
|
keeporg db -id <db-id> -store-password # checked against the stored file
|
|
keeporg db -id <db-id> -strategy merge
|
|
keeporg db -id <db-id> -forget-password # the server can no longer read it
|
|
```
|
|
|
|
### Running it in a container
|
|
|
|
```sh
|
|
make docker # keeporgd:<version>, about 25 MB
|
|
make docker-save # dist/keeporgd-<version>-oci.tar
|
|
```
|
|
|
|
The image is `scratch` plus the static binary and a CA bundle, running as
|
|
uid 65532. There is no shell in it, so `HEALTHCHECK` runs the binary with
|
|
`-healthcheck`, which probes `/healthz` on the configured address.
|
|
`make docker` uses podman when it is installed and docker otherwise; podman
|
|
needs `--format docker` for the image to keep its healthcheck, which the
|
|
Makefile passes.
|
|
|
|
Two things matter, and the whole point is that they are separate:
|
|
|
|
- the database — `/var/lib/keeporg/keeporg.db` on a volume, or PostgreSQL
|
|
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
|
|
secret plus `KEEPORG_MASTER_KEY_FILE` naming where it landed, which is the
|
|
convention compose, swarm and Kubernetes all follow:
|
|
`--secret keeporg_master_key -e KEEPORG_MASTER_KEY_FILE=/run/secrets/keeporg_master_key`.
|
|
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.
|
|
`KEEPORG_MASTER_KEY` and a volume on `/etc/keeporg` also work. A container
|
|
started with none of those mints a key into its own layer and loses it on
|
|
the next `run`, and the next start then refuses to run:
|
|
|
|
```
|
|
start: database …: this master key does not open this database; start with the key it was created with
|
|
```
|
|
|
|
Nothing is overwritten. Restore the original key and it starts.
|
|
|
|
`packaging/docker/compose.yaml` is a worked example with `read_only`,
|
|
`cap_drop: ALL` and `no-new-privileges`; the server writes one directory and
|
|
needs no capabilities.
|
|
|
|
## Versioning and publishing
|
|
|
|
`versions.mk` is the only place a version lives, one per platform plus the
|
|
Android `versionCode`. Binaries are stamped at build time through
|
|
`-ldflags -X main.Version=...`.
|
|
|
|
```sh
|
|
make bump PLATFORM=android PART=minor # also increments versionCode
|
|
make changelog MSG="what changed" # prepends to the Debian changelog
|
|
```
|
|
|
|
Application identifiers follow `cc.tiredsysadmin.<os>.<app>`:
|
|
`cc.tiredsysadmin.android.keeporg`, `.macos.keeporg`, `.linux.keeporg`,
|
|
`.windows.keeporg`, `.bsd.keeporg`, and `.linux.keeporgd` for the server
|
|
package.
|
|
|
|
Desktop and server artifacts stay local (`dist/`):
|
|
|
|
- `make dmg` (on a Mac): `KeepOrg.app`, whose executable is the client
|
|
itself, with an icon made by `iconutil` from `tools/genicon`'s iconset and
|
|
`LSUIElement` set, so there is a menu-bar item and no Dock icon.
|
|
- `make deb`: `/usr/bin/keeporg`, a desktop entry that starts the tray, the
|
|
hicolor icons, and the systemd user unit for headless `keeporg run`.
|
|
- `make windows`: `keeporg.exe`, a console program for the commands, and
|
|
`keeporg-tray.exe`, the same code linked `-H windowsgui` so the tray opens
|
|
no console window. They cannot be called `keeporg.exe` and `KeepOrg.exe`:
|
|
Windows file names are case-insensitive.
|
|
|
|
Android publishing is
|
|
manual, never automatic:
|
|
|
|
```sh
|
|
make android # unsigned APK, named cc.tiredsysadmin.android.keeporg_<code>.apk
|
|
make android-lint # kept at zero findings
|
|
make android-publish # scp to the private F-Droid unsigned and metadata directories
|
|
```
|
|
|
|
Then sign and index on the repository host (`fdroid publish && fdroid update`).
|
|
|
|
## Testing
|
|
|
|
```sh
|
|
make check # gofmt, go vet, go test ./...
|
|
make test-race # the same suite under the race detector
|
|
|
|
# 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:
|
|
|
|
- `internal/totp` against the RFC 6238 vectors.
|
|
- `internal/store` for grant inheritance, sibling isolation, admin
|
|
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
|
|
pass over login, stale push, forced push, idempotent push, read-only
|
|
grants, invite redemption and reuse, TOTP enrolment, device login and the
|
|
event stream.
|
|
- `internal/kdbx` for ChaCha20 (RFC 8439), BLAKE2b (RFC 7693) and all three
|
|
Argon2 variants (RFC 9106) against their published vectors; round trips
|
|
that preserve unknown elements; rejection of a wrong password, a flipped
|
|
payload byte and a flipped header byte; and twelve merge cases covering
|
|
both conflict directions with history, deletion, resurrection, nesting,
|
|
relocation, attachment remapping, idempotence and order independence.
|
|
Three tests drive keepassxc-cli: it reads what this code writes, this code
|
|
reads what it writes back, and it decrypts an entry that reached a vault
|
|
only through a merge.
|
|
- `internal/server` additionally for vault creation, two clients editing the
|
|
same version being merged rather than refused, the newer edit of one entry
|
|
winning, a merge failure falling back to a conflict with the stored vault
|
|
untouched, the refusal to switch to `merge` without a stored key, and the
|
|
web interface: cookie hardening, CSRF rejection, group scoping and that a
|
|
vault created from the form opens with its password.
|
|
- `internal/client` for the decision table, and for the mirror driven against
|
|
a real in-process server: adopting a local file, following a remote push,
|
|
server-wins, client-wins, manual, read-only restore, server-side rename,
|
|
and an update arriving over the event stream; for the configuration's
|
|
defaults, validation and `~` expansion; and for the mirror lock admitting
|
|
one agent.
|
|
- `cmd/keeporg` for the tray's status line and database list, which state
|
|
changes notify, configuration reload and a broken edit, the notifications
|
|
setting, and the agent against a real server through a sync and a
|
|
revocation.
|
|
- `internal/icon` for the padlock's shape and the ICO container.
|
|
|
|
The tray's menu itself has no automated test: it needs a desktop session.
|
|
It was run on macOS against a local server; on Linux and Windows it has been
|
|
compiled, not run.
|
|
|
|
The Android module is checked by `make android-lint`, which is kept at zero
|
|
findings, and `make android` builds the unsigned release APK. See
|
|
[../android/README.md](../android/README.md) for its requirements, including
|
|
what an arm64 Linux build host needs.
|
|
|
|
## Known limits and the next things to do
|
|
|
|
- **The tray has only been run on macOS.** The Linux, BSD and Windows builds
|
|
compile and their logic is tested, but nobody has looked at the icon in a
|
|
KDE panel or the Windows notification area yet.
|
|
- **GNOME shows no tray** without the AppIndicator extension. `keeporg run`
|
|
or the systemd user unit work there regardless.
|
|
- **No notifications on Windows.** A toast needs either a COM interop layer
|
|
or PowerShell; the status line shows the same states meanwhile.
|
|
- **The DMG is per architecture.** A universal `.app` needs `lipo` over both
|
|
cgo builds, which `midi-webhook`'s Makefile shows how to do.
|
|
|
|
- **Several server nodes share a database, but not their event streams.**
|
|
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
|
|
an implementation gap: the alternative is a client-side merge, which would
|
|
mean the pushing client fetching the server copy, merging locally and
|
|
pushing the result. That is a worthwhile future option and would let a
|
|
deployment keep the key off the server entirely.
|
|
- **KDBX 3 files cannot be merged.** They are reported as unsupported, not
|
|
mangled; re-save such a file as KDBX 4 in KeePassXC, or keep it on a
|
|
key-less strategy. Reading KDBX 3 is a contained addition: a different
|
|
payload layout and the Salsa20 inner stream.
|
|
- **A merge vault must keep one password.** If a client changes the vault's
|
|
own password, the server's stored key stops working and pushes come back as
|
|
`merge_failed` until an administrator stores the new one. The server does
|
|
not detect the change in advance.
|
|
- **Key files and hardware keys are not supported for merge vaults**, because
|
|
the server would have to hold those too. They work fine on any key-less
|
|
strategy, where the server never opens the file.
|
|
- **No transformed-key cache.** Each merge pays the Argon2 cost three times.
|
|
- **The Android app has run only on an emulator, and only to sign in.**
|
|
Browser sign-in was driven on an Android 15 emulator against a local
|
|
server: approval collected after the process was killed and the return
|
|
link followed, and the fallback against a 0.8 server. The file-picker
|
|
integration, the save-triggered sync and the foreground notification
|
|
still await a first real run.
|
|
- **Lint needs JDK 17.** AGP 8.7's lint cannot parse the version string of
|
|
newer JDKs (Homebrew's Gradle runs on JDK 26): run it as
|
|
`JAVA_HOME=$(/usr/libexec/java_home -v 17) make android-lint`. It reports
|
|
one warning, `OldTargetApi`, since API 36 and 37 SDKs exist; raising
|
|
`targetSdk` is its own change.
|
|
- **No key-file or Yubikey handling**, and none needed: those live in the
|
|
KeePass application.
|
|
- **No QR code for two-factor setup.** The account page shows the key and
|
|
an `otpauth:` link, which a phone opens straight into its authenticator;
|
|
scanning from a desktop screen needs a QR encoder, which the standard
|
|
library does not have.
|
|
- **The CLI has no `sessions` command yet.** The API is there
|
|
(`/api/v1/auth/sessions`); the web page is the way to see and revoke
|
|
devices meanwhile.
|
|
- **Android does not restart its watcher at boot.** Add a
|
|
`RECEIVE_BOOT_COMPLETED` receiver if that matters more than the permission
|
|
it costs.
|