The image passes neither -config nor -url, so a deployment had no way to set external_url and the server built invite links, device-approval pages and the OIDC redirect URI on http://localhost:8765. The variable overrides the configuration file and -url overrides it. The deployment values set it to https://keeporg.tiredsysadmin.cc. Also document every environment variable the server reads, and the standard proxy and CA variables it honours for OIDC. Server version 0.8.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
857 lines
43 KiB
Markdown
857 lines
43 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, JSON metadata store, ACL resolution
|
|
internal/blob/ versioned KDBX payload storage on disk
|
|
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 data directory, no database engine:
|
|
|
|
- `state.json` holds users, groups, memberships, databases, grants, invites,
|
|
sessions and pending device logins. For a merge vault it also holds that
|
|
vault's password, encrypted under the server's master key.
|
|
- `blobs/<database-id>/<version>.kdbx` holds the payloads, keeping the last
|
|
20 versions of each database.
|
|
- The master key itself lives **outside** the data directory, so a copy of
|
|
the data directory alone yields ciphertext.
|
|
|
|
`internal/store.Store` is the only writer of `state.json`. Every mutation
|
|
runs through `Update(func(*State) error)`, which applies the change to a
|
|
private copy, writes it with `write` + `fsync` + `rename`, and only then
|
|
publishes it. A failed mutation changes nothing, and a crash leaves either
|
|
the old document or the new one.
|
|
|
|
Reads go through `Read(func(*State))` under a read lock. Access decisions are
|
|
pure functions over `*State` in `internal/store/acl.go`, which is why they
|
|
are cheap enough to call per request and easy to test exhaustively.
|
|
|
|
### Desktop client (`keeporg`)
|
|
|
|
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.
|
|
|
|
### 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.
|
|
|
|
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 none of the existing vaults. Without
|
|
any of the three the server still runs and still serves every key-less
|
|
strategy; only vault creation and merging are refused, with a line in the
|
|
log saying so.
|
|
|
|
What this buys and what it does not:
|
|
|
|
- A stolen copy of `/var/lib/keeporg` is useless on its own. The vaults are
|
|
ciphertext and the wrapped keys do not open without the master key.
|
|
- A stolen copy of the data directory **and** the master key opens every
|
|
merge vault. Keep them on different media, or feed the key in through
|
|
`KEEPORG_MASTER_KEY` from a secret manager or a systemd credential so it
|
|
never touches the disk at all.
|
|
- Backups must cover both, separately. A backup of the data directory with no
|
|
master key cannot be used to merge after a restore; the vaults still open
|
|
for their users, and the keys can be stored again with
|
|
`keeporg db -id <id> -store-password`.
|
|
|
|
## Authentication
|
|
|
|
Three ways in, one kind of credential out: an opaque bearer token stored in
|
|
`state.json` with an expiry (90 days by default).
|
|
|
|
- **Password** (`POST /api/v1/auth/login`), with the built-in TOTP second
|
|
factor when the account has enrolled one. The server answers
|
|
`totp_required` so a client can ask for the code without guessing.
|
|
- **Invite link.** A group administrator creates one; the invitee opens it in
|
|
a browser, picks a username and password, and lands in the invite's group
|
|
with the invite's role.
|
|
- **Browser-approved device login**, which is also how OIDC works. The client
|
|
gets a short user code, the user approves it in a browser with a password
|
|
or with single sign-on, and the client polls until a token appears. The
|
|
same flow serves a headless desktop and an Android phone.
|
|
|
|
OIDC is implemented against the discovery document with the standard library
|
|
only. The server is a confidential client: it exchanges the authorization
|
|
code over TLS with the token endpoint and reads the identity from the
|
|
userinfo endpoint, so there is no local JWT verification to get wrong.
|
|
|
|
### 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": ""
|
|
}
|
|
```
|
|
|
|
`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>`, and approving it releases the
|
|
token the waiting client is polling for.
|
|
|
|
How an identity becomes an account:
|
|
|
|
1. If some account is already linked to this issuer and `sub`, that one.
|
|
2. Otherwise an unlinked local account whose username equals
|
|
`preferred_username`, or whose email matches `email`, is linked to it.
|
|
This is how someone invited by link later moves to single sign-on.
|
|
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
|
|
with "no account is linked to this identity; redeem an invite link
|
|
first", which is the setting to keep if membership should stay by
|
|
invitation.
|
|
|
|
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, your own account 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, 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.
|
|
|
|
**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.
|
|
|
|
## Decisions worth knowing
|
|
|
|
**No third-party Go dependencies in the server, no cgo outside the macOS
|
|
tray.** The server builds with `CGO_ENABLED=0` from the standard library
|
|
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
|
|
KDBX format, 60 lines of HMAC for TOTP, `html/template` for the pages. There
|
|
is no dependency tree to audit in the program that holds the vault keys;
|
|
`go list -deps ./cmd/keeporgd` shows only the standard library and this
|
|
module, and the container builds without fetching anything.
|
|
|
|
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 JSON document instead of SQLite.** The metadata is hundreds of records,
|
|
never thousands: users, groups, grants. One writer with atomic rename gives
|
|
durability and consistency without cgo or a migration story, and an operator
|
|
can read and back up the whole server state with `cat`. The cost is a single
|
|
node and a full rewrite per mutation. If this ever needs several servers or
|
|
tens of thousands of users, replace `internal/store` — the rest of the code
|
|
only touches it through `Read`, `Update` and the ACL functions.
|
|
|
|
**A mirror directory instead of FUSE.** The requirement is that a KeePass
|
|
application sees a regular `.kdbx` file. A real file in a real directory is
|
|
that, on Linux, macOS, Windows and BSD alike, with no kernel module, no
|
|
WinFsp or macFUSE dependency, no cgo and nothing to break on an OS upgrade.
|
|
Local writes are atomic (`write` + `rename`), so an application never reads
|
|
half a database. The price is that synchronization happens on a save rather
|
|
than on a `write()` syscall, which is exactly the granularity KDBX has
|
|
anyway.
|
|
|
|
**SSE instead of WebSocket.** The channel only carries server-to-client
|
|
notifications. SSE is a `GET` that survives proxies, reconnects by itself and
|
|
needs no library on any of the three clients. A subscriber that falls behind
|
|
is dropped rather than waited for; it reconnects and reconciles from scratch,
|
|
because every notification only ever triggers a full reconcile.
|
|
|
|
**Versions, not just a digest.** Keeping the last 20 blobs makes a rejected
|
|
push recoverable, makes "who broke the database" answerable, and costs a few
|
|
megabytes.
|
|
|
|
## Operating it
|
|
|
|
```sh
|
|
make deb # keeporg and keeporgd packages for this arch
|
|
sudo dpkg -i dist/keeporgd_*.deb
|
|
sudo nano /etc/keeporg/server.json
|
|
sudo systemctl enable --now keeporgd
|
|
sudo journalctl -u keeporgd # the first administrator's password is logged once
|
|
```
|
|
|
|
### 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 state file 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. The server must be stopped: a running one holds the state in
|
|
memory and would write over the change. 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",
|
|
"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 four variables of its own: the public URL, 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 has the same effect as a missing 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 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_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_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_ADMIN_PASSWORD` is not set there, so the first start logs a
|
|
generated password once.
|
|
|
|
Back up `/var/lib/keeporg` **and** `/etc/keeporg/master.key`, separately.
|
|
The data directory is the whole server: `state.json` plus the blobs. The
|
|
master key is what turns the wrapped vault keys in it back into vault
|
|
passwords; without it a restored server can still serve, but can no longer
|
|
merge.
|
|
|
|
### 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 9 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 directories matter, and the whole point is that they are separate:
|
|
|
|
- `/var/lib/keeporg` — state and blobs. Mount a volume.
|
|
- `/etc/keeporg` — `server.json` and, by default, the master key.
|
|
|
|
**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`. That is survivable but
|
|
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.
|
|
```
|
|
|
|
— and a merging push then answers `409 merge_failed` rather than corrupting
|
|
anything. Restore the original key and it resumes.
|
|
|
|
`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
|
|
```
|
|
|
|
What the suite actually covers:
|
|
|
|
- `internal/totp` against the RFC 6238 vectors.
|
|
- `internal/store` for grant inheritance, sibling isolation, admin
|
|
inheritance, cycle safety, update rollback and expiry.
|
|
- `internal/server` for every push verdict as a table, and an end-to-end HTTP
|
|
pass over login, stale push, forced push, idempotent push, read-only
|
|
grants, invite redemption and reuse, TOTP enrolment, device login and the
|
|
event stream.
|
|
- `internal/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.
|
|
|
|
- **One server node.** The JSON store is single-writer by design. Horizontal
|
|
scale means replacing `internal/store`.
|
|
- **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 never run on a device.** It compiles clean, lint
|
|
reports no issues and the DEX carries every class the manifest names, but
|
|
no emulator or phone was available here. The first real run is where to
|
|
confirm the file-picker integration, the save-triggered sync and the
|
|
foreground notification.
|
|
- **No key-file or Yubikey handling**, and none needed: those live in the
|
|
KeePass application.
|
|
- **Sessions are not listable or revocable individually** beyond
|
|
`keeporg logout` on the device itself. Adding `GET`/`DELETE
|
|
/api/v1/auth/sessions` is a small, well-bounded job.
|
|
- **Android does not restart its watcher at boot.** Add a
|
|
`RECEIVE_BOOT_COMPLETED` receiver if that matters more than the permission
|
|
it costs.
|