P5-01 — Documentation site under docs/book/ rendered with mdBook
(downloaded via Makefile, same static-binary pattern as Tailwind).
Structured chapters: getting started, concepts, operations,
security, reference. `make docs` / `make docs-watch`. Generated
output gitignored.
P5-02 — CONTRIBUTING.md rewritten from placeholder to a full
guide. CODE_OF_CONDUCT.md adapted from Contributor Covenant for a
single-maintainer project. .gitea/issue_template/{bug,feature}.md
and PULL_REQUEST_TEMPLATE.md.
P5-04 — Six README screenshots captured live from a fresh server
bootstrap (login, empty dashboard, add-host, alerts, settings,
audit log). README rewritten to centre the screenshot grid and
link out to the docs site.
P5-05 — SECURITY.md with disclosure policy (3-day ack, 30-day
default window), scope in/out, threat-model summary, operator
hardening checklist. Mirrored as a docs-site chapter.
P5-06 — End-to-end test harness. e2e/compose.e2e.yml brings up
server + sibling Linux agent (alpine + restic) + restic/rest-server.
Agent uses announce-and-approve so Playwright can drive the full
operator flow: bootstrap → login → accept pending → backup →
verify terminal status. Second spec scrapes /metrics to assert
the P6-04 endpoint surface. .gitea/workflows/e2e.yml runs on every
PR; local how-to in docs/e2e.md.
4.0 KiB
Credentials and how they flow
restic-manager handles three credential surfaces:
- Operator credentials — the username + password (or OIDC identity) that logs into the UI.
- Agent bearer tokens — issued at enrolment, used by the agent to authenticate its WebSocket to the server.
- Repo credentials — the rest-server / S3 / B2 / SFTP
credentials the agent passes to
resticitself.
Each has a different threat model and storage strategy.
Operator credentials
- Local users are stored in
userswith a bcrypt password hash. - Sessions are random tokens minted at login, stored hashed in
the
sessionstable, expired after 24h. Cookie is HttpOnly, SameSite=Lax, and Secure (whenRM_COOKIE_SECURE=true, default). - OIDC users carry
auth_source='oidc'and anoidc_subjectpinning their IdP identity. Local password login is rejected for OIDC users. - Disabling a user soft-deletes them via
disabled_at— pre-existing sessions are invalidated on the next request.
Agent bearer tokens
- Minted at enrolment, hashed at rest with
auth.HashToken. - The plaintext token only exists in memory at enrolment time
and on the agent's filesystem (
/etc/restic-manager/agent.yaml, mode0600, owned by the service user). - Compromise of the server DB leaks the hashes, which is enough to log in as that agent until you revoke. Compromise of the agent host leaks the plaintext (via the config file) — same end result.
- Rotation: re-enrol the host. Today there's no in-place rotate; the operator deletes the host (which cascades, including revoking the bearer hash) and re-runs the install command.
Repo credentials
This is the credential that ultimately matters for backup integrity. restic-manager keeps two slots per host:
- The everyday credential (
host_credentials.kind = ''). Append-only-friendly: this is the one your backup schedule uses. It can write but not delete or forget. - The admin credential (
host_credentials.kind = 'admin'). Has full delete rights. Only pushed to the agent transiently while apruneorforgetjob is dispatching, and discarded by the agent after the job ends.
Encryption flow
- Operator types the credential into the UI or the install form.
- Server AEAD-encrypts the cred (
crypto.AEAD.Encrypt) using the key inRM_SECRET_KEY_FILE. The plaintext is dropped from memory. - Encrypted blob is stored in
host_credentials.cred_blob. - When the agent connects, the server decrypts the blob and
sends the plaintext down the WebSocket inside a
config.updateenvelope. - The agent stores the plaintext in its in-memory secrets store for the lifetime of the process; it's reloaded fresh on every server-side push.
- When a job runs, the agent merges the credential into the
restic environment (
restic.Env.RepoURLstays bare; theuser:pass@…form is built only insideenvSlice()at the moment ofexec.Command).
The merged form is never logged. The slog package's structured
output gets restic.RedactURL() for any URL it has cause to
mention.
Why push plaintext over the wire?
The transport itself is the trust boundary: the WebSocket runs inside the same TLS-terminated reverse-proxy connection your browser uses, and the agent has already authenticated with its bearer token. Re-encrypting the payload on top of that would just move the key-management problem somewhere else.
If your reverse proxy isn't TLS-terminated, the deployment is already broken — see Hardening.
Setup tokens (admin-driven)
When an admin creates a new user, the server mints a one-time setup link valid for 1 hour. The hash is stored; the raw token is shown to the admin once. The user opens the link, sets a password, and is dropped into a session. Expired tokens are swept on the alert engine's 60s tick.
Same pattern for enrolment tokens: the raw token only exists in memory at mint time, and the install snippet is the operator's only chance to capture it. If you lose it, regenerate via the Add host page (NS-02).