Add WebAuthn config; Docker single-mount

- Add [webauthn] section to all config examples
- Add active WebAuthn config to run/mcias.conf
- Update Dockerfile to use /srv/mcias single mount
- Add WebAuthn and TOTP sections to RUNBOOK.md
- Fix TOTP QR display (template.URL type)
- Add --force-rm to docker build in Makefile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 18:57:06 -07:00
parent 37afc68287
commit 0b37fde155
9 changed files with 144 additions and 23 deletions

View File

@@ -6,7 +6,7 @@
# The final image: # The final image:
# - Runs as non-root uid 10001 (mcias) # - Runs as non-root uid 10001 (mcias)
# - Exposes port 8443 (REST/TLS) and 9443 (gRPC/TLS) # - Exposes port 8443 (REST/TLS) and 9443 (gRPC/TLS)
# - Declares VOLUME /data for the SQLite database # - Declares VOLUME /srv/mcias for config, TLS, and database
# - Does NOT contain the Go toolchain, source code, or build cache # - Does NOT contain the Go toolchain, source code, or build cache
# #
# Build: # Build:
@@ -15,8 +15,7 @@
# Run: # Run:
# docker run -d \ # docker run -d \
# --name mcias \ # --name mcias \
# -v /path/to/config:/etc/mcias:ro \ # -v /srv/mcias:/srv/mcias \
# -v mcias-data:/data \
# -e MCIAS_MASTER_PASSPHRASE=your-passphrase \ # -e MCIAS_MASTER_PASSPHRASE=your-passphrase \
# -p 8443:8443 \ # -p 8443:8443 \
# -p 9443:9443 \ # -p 9443:9443 \
@@ -72,17 +71,15 @@ COPY --from=builder /out/mciasctl /usr/local/bin/mciasctl
COPY --from=builder /out/mciasdb /usr/local/bin/mciasdb COPY --from=builder /out/mciasdb /usr/local/bin/mciasdb
COPY --from=builder /out/mciasgrpcctl /usr/local/bin/mciasgrpcctl COPY --from=builder /out/mciasgrpcctl /usr/local/bin/mciasgrpcctl
# Create the config and data directories. # Create the data directory.
# /etc/mcias is mounted read-only by the operator with the config file, # /srv/mcias is mounted from the host with config, TLS certs, and database.
# TLS cert, and TLS key. RUN mkdir -p /srv/mcias && \
# /data is the SQLite database mount point. chown mcias:mcias /srv/mcias && \
RUN mkdir -p /etc/mcias /data && \ chmod 0750 /srv/mcias
chown mcias:mcias /data && \
chmod 0750 /data
# Declare /data as a volume so the operator must explicitly mount it. # Declare /srv/mcias as a volume so the operator must explicitly mount it.
# The SQLite database must persist across container restarts. # Contains the config file, TLS cert/key, and SQLite database.
VOLUME /data VOLUME /srv/mcias
# REST/TLS port and gRPC/TLS port. These are documentation only; the actual # REST/TLS port and gRPC/TLS port. These are documentation only; the actual
# ports are set in the config file. Override by mounting a different config. # ports are set in the config file. Override by mounting a different config.
@@ -93,7 +90,8 @@ EXPOSE 9443
USER mcias USER mcias
# Default entry point and config path. # Default entry point and config path.
# The operator mounts /etc/mcias/mcias.conf from the host or a volume. # The operator mounts /srv/mcias from the host containing mcias.toml,
# TLS cert/key, and the SQLite database.
# See dist/mcias.conf.docker.example for a suitable template. # See dist/mcias.conf.docker.example for a suitable template.
ENTRYPOINT ["mciassrv"] ENTRYPOINT ["mciassrv"]
CMD ["-config", "/etc/mcias/mcias.conf"] CMD ["-config", "/srv/mcias/mcias.toml"]

View File

@@ -134,7 +134,7 @@ dist: man
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: docker .PHONY: docker
docker: docker:
docker build -t mcias:$(VERSION) -t mcias:latest . docker build --force-rm -t mcias:$(VERSION) -t mcias:latest .
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# docker-clean — remove local mcias Docker images # docker-clean — remove local mcias Docker images

View File

@@ -322,6 +322,83 @@ mciasdb $CONF audit query --json
--- ---
## WebAuthn / Passkey Configuration
WebAuthn enables passwordless passkey login and hardware security key 2FA.
It is **disabled by default** — to enable it, add a `[webauthn]` section to
`mcias.toml` with the relying party ID and origin.
### Enable WebAuthn
Add to `/srv/mcias/mcias.toml`:
```toml
[webauthn]
rp_id = "auth.example.com"
rp_origin = "https://auth.example.com"
display_name = "MCIAS"
```
- **`rp_id`** — The domain name (no scheme or port). Must match the domain
users see in their browser address bar.
- **`rp_origin`** — The full HTTPS origin. Include the port if non-standard
(e.g., `https://localhost:8443` for development).
- **`display_name`** — Shown to users during browser passkey prompts. Defaults
to "MCIAS" if omitted.
Restart the server after changing the config:
```sh
systemctl restart mcias
```
Once enabled, the **Passkeys** section appears on the user's Profile page
(self-service enrollment) and on the admin Account Detail page (credential
management).
### Passkey enrollment
Passkey enrollment is self-service only. Users add passkeys from their
**Profile → Passkeys** section. Admins can view and remove passkeys from
the Account Detail page but cannot enroll on behalf of users (passkey
registration requires the authenticator device to be present).
### Disable WebAuthn
Remove or comment out the `[webauthn]` section and restart. Existing
credentials remain in the database but are unused. Passkey UI sections
will be hidden.
### Remove all passkeys for an account (break-glass)
```sh
mciasdb --config /srv/mcias/mcias.toml account reset-webauthn --id <UUID>
```
---
## TOTP Two-Factor Authentication
TOTP enrollment is self-service via the **Profile → Two-Factor Authentication**
section. Users enter their current password to begin enrollment, scan the QR
code with an authenticator app, and confirm with a 6-digit code.
### Admin: Remove TOTP for an account
From the web UI: navigate to the account's detail page and click **Remove**
next to the TOTP status.
From the CLI:
```sh
mciasdb --config /srv/mcias/mcias.toml account reset-totp --id <UUID>
```
This clears the TOTP secret and disables the 2FA requirement. The user can
re-enroll from their Profile page.
---
## Master Key Rotation ## Master Key Rotation
> This operation is not yet automated. Until a rotation command is > This operation is not yet automated. Until a rotation command is

View File

@@ -41,3 +41,10 @@ threads = 4
[master_key] [master_key]
passphrase_env = "MCIAS_MASTER_PASSPHRASE" passphrase_env = "MCIAS_MASTER_PASSPHRASE"
# WebAuthn — passkey authentication for local development.
# rp_origin includes the non-standard port since we're not behind a proxy.
[webauthn]
rp_id = "localhost"
rp_origin = "https://localhost:8443"
display_name = "MCIAS (dev)"

View File

@@ -48,3 +48,14 @@ threads = 4
# Set it with: docker run -e MCIAS_MASTER_PASSPHRASE=your-passphrase ... # Set it with: docker run -e MCIAS_MASTER_PASSPHRASE=your-passphrase ...
# or with a Docker secret / Kubernetes secret. # or with a Docker secret / Kubernetes secret.
passphrase_env = "MCIAS_MASTER_PASSPHRASE" passphrase_env = "MCIAS_MASTER_PASSPHRASE"
# ---------------------------------------------------------------------------
# [webauthn] — FIDO2/WebAuthn passkey authentication (OPTIONAL)
# ---------------------------------------------------------------------------
# Uncomment to enable passwordless passkey login. Set rp_id to your domain
# and rp_origin to the full HTTPS origin users access in their browser.
#
# [webauthn]
# rp_id = "auth.example.com"
# rp_origin = "https://auth.example.com"
# display_name = "MCIAS"

View File

@@ -123,3 +123,24 @@ passphrase_env = "MCIAS_MASTER_PASSPHRASE"
# #
# Uncomment and comment out passphrase_env to switch modes. # Uncomment and comment out passphrase_env to switch modes.
# keyfile = "/srv/mcias/master.key" # keyfile = "/srv/mcias/master.key"
# ---------------------------------------------------------------------------
# [webauthn] — FIDO2/WebAuthn passkey authentication (OPTIONAL)
# ---------------------------------------------------------------------------
# Enables passwordless passkey login and hardware security key 2FA.
# If this section is omitted or rp_id/rp_origin are empty, WebAuthn is
# disabled and passkey options will not appear in the UI.
#
# [webauthn]
#
# REQUIRED (if enabling). The Relying Party ID — typically the domain name
# (without port or scheme). Must match the domain users see in their browser.
# rp_id = "auth.example.com"
#
# REQUIRED (if enabling). The Relying Party Origin — the full origin URL
# including scheme. Must be HTTPS. Include the port if non-standard (not 443).
# rp_origin = "https://auth.example.com"
#
# OPTIONAL. Display name shown to users during passkey registration prompts.
# Default: "MCIAS".
# display_name = "MCIAS"

View File

@@ -4,6 +4,7 @@ import (
"encoding/base32" "encoding/base32"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
qrcode "github.com/skip2/go-qrcode" qrcode "github.com/skip2/go-qrcode"
@@ -108,7 +109,7 @@ func (u *UIServer) handleTOTPEnrollStart(w http.ResponseWriter, r *http.Request)
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"}) u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return return
} }
qrDataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) qrDataURI := template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) //nolint:gosec // G203: trusted server-generated data URI
// Issue enrollment nonce for the confirm step. // Issue enrollment nonce for the confirm step.
nonce, err := u.issueTOTPEnrollNonce(acct.ID) nonce, err := u.issueTOTPEnrollNonce(acct.ID)
@@ -224,7 +225,7 @@ func (u *UIServer) reissueTOTPEnrollQR(w http.ResponseWriter, r *http.Request, a
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"}) u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
return return
} }
qrDataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) qrDataURI := template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) //nolint:gosec // G203: trusted server-generated data URI
newNonce, nonceErr := u.issueTOTPEnrollNonce(acct.ID) newNonce, nonceErr := u.issueTOTPEnrollNonce(acct.ID)
if nonceErr != nil { if nonceErr != nil {

View File

@@ -931,11 +931,11 @@ type ProfileData struct { //nolint:govet // fieldalignment: readability over ali
WebAuthnEnabled bool WebAuthnEnabled bool
// TOTP enrollment fields (populated only during enrollment flow). // TOTP enrollment fields (populated only during enrollment flow).
TOTPEnabled bool TOTPEnabled bool
TOTPSecret string // base32-encoded; shown once during enrollment TOTPSecret string // base32-encoded; shown once during enrollment
TOTPQR string // data:image/png;base64,... QR code TOTPQR template.URL // data:image/png;base64,... QR code; template.URL bypasses URL escaping
TOTPEnrollNonce string // single-use nonce for confirm step TOTPEnrollNonce string // single-use nonce for confirm step
TOTPError string // enrollment-specific error message TOTPError string // enrollment-specific error message
TOTPSuccess string // success flash after confirmation TOTPSuccess string // success flash after confirmation
} }
// PGCredsData is the view model for the "My PG Credentials" list page. // PGCredsData is the view model for the "My PG Credentials" list page.

View File

@@ -60,6 +60,12 @@
<div class="card"> <div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Passkeys</h2> <h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Passkeys</h2>
{{template "webauthn_credentials" .}} {{template "webauthn_credentials" .}}
{{if eq (string .Account.AccountType) "human"}}
<p class="text-muted text-small" style="margin-top:.75rem">
Passkey enrollment is self-service. The account holder can add passkeys from their
<a href="/profile">Profile</a> page.
</p>
{{end}}
</div> </div>
{{end}} {{end}}
<div class="card"> <div class="card">