Why: two questions about operating this turned up two gaps. "Where do I get the admin password" had no answer once the line scrolled out of the log, and "how do I set up OIDC" ran straight into a sign-in link that could not work. What: - /oidc/login with no parameters now signs a browser in and ends in a session cookie. It previously required the user code of a waiting client, so the link on the web sign-in page always failed. With a code it still approves a device, and the two paths no longer overlap: an empty user code matches no pending device. - POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults. Changing a password requires the current one, and the one-time code when a second factor is enrolled, and revokes that account's other sessions. Until now nothing could change a password, which made the advice printed at first start impossible to follow. - keeporgd -reset-admin, for when that first password was not kept: a new one printed once, any second factor cleared, sessions revoked. It writes the state document directly and says to stop the server first. - The client read stdin through a new buffered reader per prompt, so the first prompt swallowed the rest and anything asking twice could not be scripted. One shared reader now. A prompt for a password being set no longer reads $KEEPORG_PASSWORD either; that variable holds the password you sign in with, and taking it here would have set the new password to the old one. - HANDOVER gains a first-sign-in section and an OIDC setup section covering the redirect URI, the config block, both entry points and how an identity becomes an account. Proved: OIDC now has tests, against a stub provider that serves discovery, authorization, token and userinfo. They cover the web sign-in that was broken, device approval, linking an existing account by username, the refusal when auto_create is off, and a forged callback state. Eight more cover the password change. Checked by hand with the real binaries: the first-start password logs once and works, -reset-admin replaces it while the old one stops working, and `keeporg passwd` changes it from a pipe. Server 0.5.0, desktop clients 0.3.0.
647 lines
30 KiB
Markdown
647 lines
30 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: login, run, sync, status, administration
|
|
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, pages
|
|
internal/client/ shared client engine: API wrapper, merge policy, mirror
|
|
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
|
|
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`)
|
|
|
|
`keeporg run` keeps a mirror 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.
|
|
|
|
### 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
|
|
|
|
The server has one browsable page set, at `/web`, and it exists for the one
|
|
job a command line does badly: creating a vault and setting its password.
|
|
Sign in, see which vaults you can reach, create one. It never shows a vault's
|
|
contents — the server opens a vault only to merge it — and there is no
|
|
entry browsing, by design.
|
|
|
|
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 form carries a per-session CSRF token that is
|
|
checked on each POST. 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.
|
|
|
|
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, no cgo.** The whole server and desktop
|
|
client build with `CGO_ENABLED=0` from the standard library: 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.
|
|
Cross-compiling to five platforms is then one `go build` each, and there is
|
|
no dependency tree to audit for a program that guards passwords.
|
|
|
|
**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. Terminate TLS in front of
|
|
the server, or set `tls_cert` and `tls_key`.
|
|
|
|
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 create the vault at `https://keeporg.example.com/web`, which is the same
|
|
operation with a form around it.
|
|
|
|
`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/`). 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.
|
|
|
|
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
|
|
|
|
- **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.
|