5 Commits

Author SHA1 Message Date
db7cd73a6e Fix WebAuthn login: username pre-fill and policy check
- webauthn.js: read #username value before calling
  mciasWebAuthnLogin so non-discoverable keys work when
  a username is typed (previously always passed empty string,
  forcing discoverable/resident-key flow only)

- handleWebAuthnLoginFinish: evaluate auth:login policy after
  credential verification, mirroring the gate in handleLogin;
  returns 403 on deny so policy rules apply equally to both
  password and passkey authentication paths

Security: policy is checked post-verification so 403 vs 401
distinguishes a policy restriction from a bad credential without
leaking account existence. No service context is sent (WebAuthn
login carries no service_name/tags), so per-service deny rules
don't fire on passkey login; account-level deny rules do.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:04:51 -07:00
39d9ffb79a Add service-context login policy enforcement
Services send service_name and tags in POST /v1/auth/login.
MCIAS evaluates auth:login policy with these as the resource
context after credentials are verified, enabling rules like:
  deny guest/viewer human accounts from env:restricted services
  deny guest accounts from specific named services

- loginRequest: add ServiceName and Tags fields
- handleLogin: evaluate policy after credential+TOTP check;
  policy deny returns 403 (not 401) to distinguish access
  restriction from bad credentials
- Go client: Options.ServiceName/Tags stored on Client,
  sent automatically in every Login() call
- Python client: service_name/tags on __init__, sent in login()
- Rust client: ClientOptions.service_name/tags, LoginRequest
  fields, Client stores and sends them in login()
- openapi.yaml: document service_name/tags request fields
  and 403 response for policy-denied logins
- engineering-standards.md: document service_name/tags in
  [mcias] config section with policy examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:11:35 -07:00
b0afe3b993 Align with engineering standards (steps 1-5)
- Rename dist/ -> deploy/ with subdirs examples/, scripts/,
  systemd/ per standard repository layout
- Update .gitignore: gitignore all of dist/ (build output only)
- Makefile: all target is now vet->lint->test->build; add vet,
  proto-lint, devserver targets; CGO_ENABLED=0 for builds
  (modernc.org/sqlite is pure-Go, no C toolchain needed);
  CGO_ENABLED=1 retained for tests (race detector)
- Dockerfile: builder -> golang:1.26-alpine, runtime ->
  alpine:3.21; drop libc6 dep; add /srv/mcias/certs and
  /srv/mcias/backups to image
- deploy/systemd/mcias.service: add RestrictSUIDSGID=true
- deploy/systemd/mcias-backup.service: new oneshot backup unit
- deploy/systemd/mcias-backup.timer: daily 02:00 UTC, 5m jitter
- deploy/scripts/install.sh: install backup units and enable
  timer; create certs/ and backups/ subdirs in /srv/mcias
- buf.yaml: add proto linting config for proto-lint target
- internal/db: add Snapshot and SnapshotDir methods (VACUUM INTO)
- cmd/mciasdb: add snapshot subcommand; no master key required
2026-03-16 20:26:43 -07:00
446b3df52d Fix WebAuthn CSRF; clarify security key UI
- Fix webauthn.js CSRF token: read HMAC header value from
  body hx-headers attribute instead of cookie nonce
- Update profile labels to mention security keys/FIDO2
  alongside passkeys

Security: CSRF double-submit was broken for fetch()-based
WebAuthn requests — JS was sending the cookie nonce as the
header value instead of the HMAC. Fixed by reading the
server-rendered header token from the DOM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:27:44 -07:00
0b37fde155 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>
2026-03-16 18:57:06 -07:00
28 changed files with 614 additions and 104 deletions

3
.gitignore vendored
View File

@@ -20,7 +20,8 @@ mcias.toml
*~ *~
go.work go.work
go.work.sum go.work.sum
dist/mcias_*.tar.gz # dist/ is purely build output (tarballs); never commit it
dist/
man/man1/*.gz man/man1/*.gz
# Client library build artifacts # Client library build artifacts

View File

@@ -1,12 +1,16 @@
# Dockerfile — MCIAS multi-stage container image # Dockerfile — MCIAS multi-stage container image
# #
# Stage 1 (builder): Compiles all four MCIAS binaries. # Stage 1 (builder): Compiles all four MCIAS binaries.
# Stage 2 (runtime): Minimal Debian image containing only the binaries. # Stage 2 (runtime): Minimal Alpine image containing only the binaries.
#
# modernc.org/sqlite is a pure-Go, CGo-free SQLite port. CGO_ENABLED=0
# produces fully static binaries with no C library dependencies, which
# deploy cleanly onto a minimal Alpine runtime image.
# #
# 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 +19,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 \
@@ -25,7 +28,7 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stage 1 — builder # Stage 1 — builder
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM golang:1.26-bookworm AS builder FROM golang:1.26-alpine AS builder
WORKDIR /build WORKDIR /build
@@ -36,35 +39,29 @@ RUN go mod download
# Copy source. # Copy source.
COPY . . COPY . .
# CGO_ENABLED=1 is required by modernc.org/sqlite (pure-Go CGo-free SQLite). # CGO_ENABLED=0: modernc.org/sqlite is pure Go; no C toolchain required.
# -trimpath removes local file system paths from the binary. # -trimpath removes local file system paths from the binary.
# -ldflags="-s -w" strips the DWARF debug info and symbol table to reduce # -ldflags="-s -w" strips the DWARF debug info and symbol table to reduce
# image size. # image size.
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasctl ./cmd/mciasctl && \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasctl ./cmd/mciasctl && \
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasdb ./cmd/mciasdb && \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasdb ./cmd/mciasdb && \
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stage 2 — runtime # Stage 2 — runtime
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FROM debian:bookworm-slim FROM alpine:3.21
# Install runtime dependencies.
# ca-certificates: required to validate external TLS certificates. # ca-certificates: required to validate external TLS certificates.
# libc6: required by CGo-compiled binaries (sqlite). RUN apk add --no-cache ca-certificates
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
libc6 && \
rm -rf /var/lib/apt/lists/*
# Create a non-root user for the service. # Create a non-root user for the service.
# uid/gid 10001 is chosen to be well above the range typically assigned to # uid/gid 10001 is chosen to be well above the range typically assigned to
# system users (1999) and human users (1000+), reducing the chance of # system users (1999) and human users (1000+), reducing the chance of
# collision with existing uids on the host when using host networking. # collision with existing uids on the host when using host networking.
RUN groupadd --gid 10001 mcias && \ RUN addgroup -g 10001 mcias && \
useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin mcias adduser -u 10001 -G mcias -H -s /sbin/nologin -D mcias
# Copy compiled binaries from the builder stage. # Copy compiled binaries from the builder stage.
COPY --from=builder /out/mciassrv /usr/local/bin/mciassrv COPY --from=builder /out/mciassrv /usr/local/bin/mciassrv
@@ -72,17 +69,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/certs /srv/mcias/backups && \
# /data is the SQLite database mount point. chown -R 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 +88,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,
# See dist/mcias.conf.docker.example for a suitable template. # TLS cert/key, and the SQLite database.
# See deploy/examples/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

@@ -3,10 +3,14 @@
# Usage: # Usage:
# make build — compile all binaries to bin/ # make build — compile all binaries to bin/
# make test — run tests with race detector # make test — run tests with race detector
# make vet — run go vet
# make lint — run golangci-lint # make lint — run golangci-lint
# make all — vet → lint → test → build (CI pipeline)
# make generate — regenerate protobuf stubs (requires protoc) # make generate — regenerate protobuf stubs (requires protoc)
# make proto-lint — lint proto files with buf
# make man — build compressed man pages # make man — build compressed man pages
# make install — run dist/install.sh (requires root) # make install — run deploy/scripts/install.sh (requires root)
# make devserver — build and run mciassrv against run/ config
# make clean — remove bin/ and generated artifacts # make clean — remove bin/ and generated artifacts
# make dist — build release tarballs for linux/amd64 and linux/arm64 # make dist — build release tarballs for linux/amd64 and linux/arm64
# make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest # make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest
@@ -27,20 +31,25 @@ MAN_PAGES := $(MAN_DIR)/mciassrv.1 $(MAN_DIR)/mciasctl.1 \
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev) VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
# Build flags: trim paths from binaries and strip DWARF/symbol table. # Build flags: trim paths from binaries and strip DWARF/symbol table.
# CGO_ENABLED=1 is required for modernc.org/sqlite. # modernc.org/sqlite is pure-Go and does not require CGo; CGO_ENABLED=0
# produces statically linked binaries that deploy cleanly to Alpine containers.
GO := go GO := go
GOFLAGS := -trimpath GOFLAGS := -trimpath
LDFLAGS := -s -w -X main.version=$(VERSION) LDFLAGS := -s -w -X main.version=$(VERSION)
CGO := CGO_ENABLED=1 CGO := CGO_ENABLED=0
# The race detector requires CGo on some platforms, so tests continue to use
# CGO_ENABLED=1 while production builds are CGO_ENABLED=0.
CGO_TEST := CGO_ENABLED=1
# Platforms for cross-compiled dist tarballs. # Platforms for cross-compiled dist tarballs.
DIST_PLATFORMS := linux/amd64 linux/arm64 DIST_PLATFORMS := linux/amd64 linux/arm64
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Default target # Default target — CI pipeline: vet → lint → test → build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: all .PHONY: all
all: build all: vet lint test build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# build — compile all binaries to bin/ # build — compile all binaries to bin/
@@ -59,7 +68,14 @@ build:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: test .PHONY: test
test: test:
$(CGO) $(GO) test -race ./... $(CGO_TEST) $(GO) test -race ./...
# ---------------------------------------------------------------------------
# vet — static analysis via go vet
# ---------------------------------------------------------------------------
.PHONY: vet
vet:
$(GO) vet ./...
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# lint — run golangci-lint # lint — run golangci-lint
@@ -68,6 +84,15 @@ test:
lint: lint:
golangci-lint run ./... golangci-lint run ./...
# ---------------------------------------------------------------------------
# proto-lint — lint and check for breaking changes in proto definitions
# Requires: buf (https://buf.build/docs/installation)
# ---------------------------------------------------------------------------
.PHONY: proto-lint
proto-lint:
buf lint
buf breaking --against '.git#branch=master,subdir=proto'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# generate — regenerate protobuf stubs from proto/ definitions # generate — regenerate protobuf stubs from proto/ definitions
# Requires: protoc, protoc-gen-go, protoc-gen-go-grpc # Requires: protoc, protoc-gen-go, protoc-gen-go-grpc
@@ -76,6 +101,13 @@ lint:
generate: generate:
$(GO) generate ./... $(GO) generate ./...
# ---------------------------------------------------------------------------
# devserver — build and run mciassrv against the local run/ config
# ---------------------------------------------------------------------------
.PHONY: devserver
devserver: build
$(BIN_DIR)/mciassrv -config run/mcias.conf
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# man — build compressed man pages # man — build compressed man pages
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -90,7 +122,7 @@ man: $(patsubst %.1,%.1.gz,$(MAN_PAGES))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: install .PHONY: install
install: build install: build
sh dist/install.sh sh deploy/scripts/install.sh
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# clean — remove build artifacts # clean — remove build artifacts
@@ -98,6 +130,7 @@ install: build
.PHONY: clean .PHONY: clean
clean: clean:
rm -rf $(BIN_DIR) rm -rf $(BIN_DIR)
rm -rf dist/
rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES)) rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES))
-docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true -docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true
@@ -106,7 +139,7 @@ clean:
# #
# Output files: dist/mcias_<version>_<os>_<arch>.tar.gz # Output files: dist/mcias_<version>_<os>_<arch>.tar.gz
# Each tarball contains: mciassrv, mciasctl, mciasdb, mciasgrpcctl, # Each tarball contains: mciassrv, mciasctl, mciasdb, mciasgrpcctl,
# man pages, and dist/ files. # man pages, and deploy/ files.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
.PHONY: dist .PHONY: dist
dist: man dist: man
@@ -117,14 +150,12 @@ dist: man
echo " DIST $$platform -> $$outdir.tar.gz"; \ echo " DIST $$platform -> $$outdir.tar.gz"; \
mkdir -p $$outdir/bin; \ mkdir -p $$outdir/bin; \
for bin in $(BINARIES); do \ for bin in $(BINARIES); do \
CGO_ENABLED=1 GOOS=$$os GOARCH=$$arch $(GO) build \ CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build \
$(GOFLAGS) -ldflags "$(LDFLAGS)" \ $(GOFLAGS) -ldflags "$(LDFLAGS)" \
-o $$outdir/bin/$$bin ./cmd/$$bin; \ -o $$outdir/bin/$$bin ./cmd/$$bin; \
done; \ done; \
cp -r man $$outdir/; \ cp -r man $$outdir/; \
cp dist/mcias.conf.example dist/mcias-dev.conf.example \ cp -r deploy $$outdir/; \
dist/mcias.env.example dist/mcias.service \
dist/install.sh $$outdir/; \
tar -czf $$outdir.tar.gz -C dist mcias_$$(echo $(VERSION) | tr -d 'v')_$${os}_$${arch}; \ tar -czf $$outdir.tar.gz -C dist mcias_$$(echo $(VERSION) | tr -d 'v')_$${os}_$${arch}; \
rm -rf $$outdir; \ rm -rf $$outdir; \
done done
@@ -134,7 +165,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
@@ -154,13 +185,17 @@ install-local: build
.PHONY: help .PHONY: help
help: help:
@echo "Available targets:" @echo "Available targets:"
@echo " build Compile all binaries to bin/" @echo " all vet → lint → test → build (CI pipeline)"
@echo " test Run tests with race detector" @echo " build Compile all binaries to bin/"
@echo " lint Run golangci-lint" @echo " test Run tests with race detector"
@echo " generate Regenerate protobuf stubs" @echo " vet Run go vet"
@echo " man Build compressed man pages" @echo " lint Run golangci-lint"
@echo " install Install to /usr/local/bin (requires root)" @echo " proto-lint Lint proto files with buf"
@echo " clean Remove build artifacts" @echo " generate Regenerate protobuf stubs"
@echo " dist Build release tarballs for Linux amd64/arm64" @echo " devserver Build and run mciassrv against run/ config"
@echo " docker Build Docker image mcias:$(VERSION) and mcias:latest" @echo " man Build compressed man pages"
@echo " install Install to /usr/local/bin (requires root)"
@echo " clean Remove build artifacts"
@echo " dist Build release tarballs for Linux amd64/arm64"
@echo " docker Build Docker image mcias:$(VERSION) and mcias:latest"
@echo " docker-clean Remove local mcias Docker images" @echo " 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

14
buf.yaml Normal file
View File

@@ -0,0 +1,14 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
except:
# PACKAGE_VERSION_SUFFIX requires package names to end in a version (e.g.
# mcias.v1). The current protos use mcias.v1 already so this is fine, but
# keeping the exception documents the intent explicitly.
- PACKAGE_VERSION_SUFFIX
breaking:
use:
- FILE

View File

@@ -185,16 +185,28 @@ type Options struct {
CACertPath string CACertPath string
// Token is an optional pre-existing bearer token. // Token is an optional pre-existing bearer token.
Token string Token string
// ServiceName is the name of this service as registered in MCIAS. It is
// sent with every Login call so MCIAS can evaluate service-context policy
// rules (e.g. deny guest users from logging into this service).
// Populate from [mcias] service_name in the service's config file.
ServiceName string
// Tags are the service-level tags sent with every Login call. MCIAS
// evaluates auth:login policy against these tags, enabling rules such as
// "deny guest accounts from services tagged env:restricted".
// Populate from [mcias] tags in the service's config file.
Tags []string
} }
// Client is a thread-safe MCIAS REST API client. // Client is a thread-safe MCIAS REST API client.
// Security: the bearer token is guarded by a sync.RWMutex; it is never // Security: the bearer token is guarded by a sync.RWMutex; it is never
// written to logs or included in error messages in this library. // written to logs or included in error messages in this library.
type Client struct { type Client struct {
baseURL string baseURL string
http *http.Client http *http.Client
mu sync.RWMutex serviceName string
token string tags []string
mu sync.RWMutex
token string
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -224,9 +236,11 @@ func New(serverURL string, opts Options) (*Client, error) {
} }
transport := &http.Transport{TLSClientConfig: tlsCfg} transport := &http.Transport{TLSClientConfig: tlsCfg}
c := &Client{ c := &Client{
baseURL: serverURL, baseURL: serverURL,
http: &http.Client{Transport: transport}, http: &http.Client{Transport: transport},
token: opts.Token, token: opts.Token,
serviceName: opts.ServiceName,
tags: opts.Tags,
} }
return c, nil return c, nil
} }
@@ -343,16 +357,28 @@ func (c *Client) GetPublicKey() (*PublicKey, error) {
// Login authenticates with username and password. On success the token is // Login authenticates with username and password. On success the token is
// stored in the Client and returned along with the expiry timestamp. // stored in the Client and returned along with the expiry timestamp.
// totpCode may be empty for accounts without TOTP. // totpCode may be empty for accounts without TOTP.
//
// The client's ServiceName and Tags (from Options) are included in the
// request so MCIAS can evaluate service-context policy rules.
func (c *Client) Login(username, password, totpCode string) (token, expiresAt string, err error) { func (c *Client) Login(username, password, totpCode string) (token, expiresAt string, err error) {
req := map[string]string{"username": username, "password": password} body := map[string]interface{}{
"username": username,
"password": password,
}
if totpCode != "" { if totpCode != "" {
req["totp_code"] = totpCode body["totp_code"] = totpCode
}
if c.serviceName != "" {
body["service_name"] = c.serviceName
}
if len(c.tags) > 0 {
body["tags"] = c.tags
} }
var resp struct { var resp struct {
Token string `json:"token"` Token string `json:"token"`
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at"`
} }
if err := c.do(http.MethodPost, "/v1/auth/login", req, &resp); err != nil { if err := c.do(http.MethodPost, "/v1/auth/login", body, &resp); err != nil {
return "", "", err return "", "", err
} }
c.setToken(resp.Token) c.setToken(resp.Token)

View File

@@ -20,9 +20,13 @@ class Client:
ca_cert_path: str | None = None, ca_cert_path: str | None = None,
token: str | None = None, token: str | None = None,
timeout: float = 30.0, timeout: float = 30.0,
service_name: str | None = None,
tags: list[str] | None = None,
) -> None: ) -> None:
self._base_url = server_url.rstrip("/") self._base_url = server_url.rstrip("/")
self.token = token self.token = token
self._service_name = service_name
self._tags = tags or []
ssl_context: ssl.SSLContext | bool ssl_context: ssl.SSLContext | bool
if ca_cert_path is not None: if ca_cert_path is not None:
ssl_context = ssl.create_default_context(cafile=ca_cert_path) ssl_context = ssl.create_default_context(cafile=ca_cert_path)
@@ -115,6 +119,9 @@ class Client:
) -> tuple[str, str]: ) -> tuple[str, str]:
"""POST /v1/auth/login — authenticate and obtain a JWT. """POST /v1/auth/login — authenticate and obtain a JWT.
Returns (token, expires_at). Stores the token on self.token. Returns (token, expires_at). Stores the token on self.token.
The client's service_name and tags are included so MCIAS can evaluate
service-context policy rules (e.g. deny guests from restricted services).
""" """
payload: dict[str, Any] = { payload: dict[str, Any] = {
"username": username, "username": username,
@@ -122,6 +129,10 @@ class Client:
} }
if totp_code is not None: if totp_code is not None:
payload["totp_code"] = totp_code payload["totp_code"] = totp_code
if self._service_name is not None:
payload["service_name"] = self._service_name
if self._tags:
payload["tags"] = self._tags
data = self._request("POST", "/v1/auth/login", json=payload) data = self._request("POST", "/v1/auth/login", json=payload)
assert data is not None assert data is not None
token = str(data["token"]) token = str(data["token"])

View File

@@ -227,6 +227,10 @@ struct LoginRequest<'a> {
password: &'a str, password: &'a str,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
totp_code: Option<&'a str>, totp_code: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
service_name: Option<&'a str>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -268,6 +272,16 @@ pub struct ClientOptions {
/// Optional pre-existing bearer token. /// Optional pre-existing bearer token.
pub token: Option<String>, pub token: Option<String>,
/// This service's name as registered in MCIAS. Sent with every login
/// request so MCIAS can evaluate service-context policy rules.
/// Populate from `[mcias] service_name` in the service config.
pub service_name: Option<String>,
/// Service-level tags sent with every login request. MCIAS evaluates
/// `auth:login` policy against these tags.
/// Populate from `[mcias] tags` in the service config.
pub tags: Vec<String>,
} }
// ---- Client ---- // ---- Client ----
@@ -280,6 +294,8 @@ pub struct ClientOptions {
pub struct Client { pub struct Client {
base_url: String, base_url: String,
http: reqwest::Client, http: reqwest::Client,
service_name: Option<String>,
tags: Vec<String>,
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token. /// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
/// Security: the token is never logged or included in error messages. /// Security: the token is never logged or included in error messages.
token: Arc<RwLock<Option<String>>>, token: Arc<RwLock<Option<String>>>,
@@ -306,6 +322,8 @@ impl Client {
Ok(Self { Ok(Self {
base_url: base_url.trim_end_matches('/').to_owned(), base_url: base_url.trim_end_matches('/').to_owned(),
http, http,
service_name: opts.service_name,
tags: opts.tags,
token: Arc::new(RwLock::new(opts.token)), token: Arc::new(RwLock::new(opts.token)),
}) })
} }
@@ -336,6 +354,8 @@ impl Client {
username, username,
password, password,
totp_code, totp_code,
service_name: self.service_name.as_deref(),
tags: self.tags.clone(),
}; };
let resp: TokenResponse = self.post("/v1/auth/login", &body).await?; let resp: TokenResponse = self.post("/v1/auth/login", &body).await?;
*self.token.write().await = Some(resp.token.clone()); *self.token.write().await = Some(resp.token.clone());

View File

@@ -40,7 +40,7 @@
// pgcreds get --id UUID // pgcreds get --id UUID
// pgcreds set --id UUID --host H --port P --db D --user U // pgcreds set --id UUID --host H --port P --db D --user U
// //
// rekey // snapshot [--retain-days N]
package main package main
import ( import (
@@ -68,6 +68,14 @@ func main() {
command := args[0] command := args[0]
subArgs := args[1:] subArgs := args[1:]
// snapshot loads only the config (no master key needed — VACUUM INTO does
// not access encrypted columns) and must be handled before openDB, which
// requires the master key passphrase env var.
if command == "snapshot" {
runSnapshot(*configPath, subArgs)
return
}
// schema subcommands manage migrations themselves and must not trigger // schema subcommands manage migrations themselves and must not trigger
// auto-migration on open (a dirty database would prevent the tool from // auto-migration on open (a dirty database would prevent the tool from
// opening at all, blocking recovery operations like "schema force"). // opening at all, blocking recovery operations like "schema force").
@@ -273,6 +281,11 @@ Commands:
rekey Re-encrypt all secrets under a new master passphrase rekey Re-encrypt all secrets under a new master passphrase
(prompts interactively; requires server to be stopped) (prompts interactively; requires server to be stopped)
snapshot Write a timestamped VACUUM INTO backup to
<db-dir>/backups/; prune backups older than
--retain-days days (default 30, 0 = keep all).
Does not require the master key passphrase.
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
file. Use it only when the server is unavailable or for break-glass recovery. file. Use it only when the server is unavailable or for break-glass recovery.
All write operations are recorded in the audit log. All write operations are recorded in the audit log.

44
cmd/mciasdb/snapshot.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"flag"
"fmt"
"path/filepath"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
)
// runSnapshot handles the "snapshot" command.
//
// It opens the database read-only (no master key derivation needed — VACUUM
// INTO does not access encrypted columns) and writes a timestamped backup to
// /srv/mcias/backups/ (or the directory adjacent to the configured DB path).
// Backups older than --retain-days are pruned.
func runSnapshot(configPath string, args []string) {
fs := flag.NewFlagSet("snapshot", flag.ExitOnError)
retainDays := fs.Int("retain-days", 30, "prune backups older than this many days (0 = keep all)")
if err := fs.Parse(args); err != nil {
fatalf("snapshot: %v", err)
}
cfg, err := config.Load(configPath)
if err != nil {
fatalf("snapshot: load config: %v", err)
}
database, err := db.Open(cfg.Database.Path)
if err != nil {
fatalf("snapshot: open database: %v", err)
}
defer func() { _ = database.Close() }()
// Place backups in a "backups" directory adjacent to the database file.
backupDir := filepath.Join(filepath.Dir(cfg.Database.Path), "backups")
dest, err := database.SnapshotDir(backupDir, *retainDays)
if err != nil {
fatalf("snapshot: %v", err)
}
fmt.Printf("snapshot written: %s\n", dest)
}

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

@@ -1,13 +1,13 @@
#!/bin/sh #!/bin/sh
# install.sh — MCIAS first-time and upgrade installer # install.sh — MCIAS first-time and upgrade installer
# #
# Usage: sh dist/install.sh # Usage: sh deploy/scripts/install.sh
# #
# This script must be run as root. It: # This script must be run as root. It:
# 1. Creates the mcias system user and group (idempotent). # 1. Creates the mcias system user and group (idempotent).
# 2. Copies binaries to /usr/local/bin/. # 2. Copies binaries to /usr/local/bin/.
# 3. Creates /srv/mcias/ with correct permissions. # 3. Creates /srv/mcias/ with correct permissions.
# 4. Installs the systemd service unit. # 4. Installs the systemd service and backup units.
# 5. Prints post-install instructions. # 5. Prints post-install instructions.
# #
# The script does NOT start or enable the service automatically. Review the # The script does NOT start or enable the service automatically. Review the
@@ -31,7 +31,8 @@ SYSTEMD_DIR="/etc/systemd/system"
SERVICE_USER="mcias" SERVICE_USER="mcias"
SERVICE_GROUP="mcias" SERVICE_GROUP="mcias"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")" DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
REPO_ROOT="$(dirname "$DEPLOY_DIR")"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -100,11 +101,7 @@ fi
# Step 2: Install binaries. # Step 2: Install binaries.
info "Installing binaries to $BIN_DIR" info "Installing binaries to $BIN_DIR"
for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
src="$REPO_ROOT/$bin" src="$REPO_ROOT/bin/$bin"
if [ ! -f "$src" ]; then
# Try bin/ subdirectory (Makefile build output).
src="$REPO_ROOT/bin/$bin"
fi
if [ ! -f "$src" ]; then if [ ! -f "$src" ]; then
warn "Binary not found: $bin — skipping. Run 'make build' first." warn "Binary not found: $bin — skipping. Run 'make build' first."
continue continue
@@ -113,30 +110,40 @@ for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin" install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin"
done done
# Step 3: Create service directory. # Step 3: Create service directory structure.
info "Creating $SRV_DIR" info "Creating $SRV_DIR"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR" install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR/certs"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR/backups"
# Install example config files; never overwrite existing configs. # Install example config files; never overwrite existing configs.
for f in mcias.conf.example mcias.env.example; do for f in mcias.conf.example mcias.env.example; do
src="$SCRIPT_DIR/$f" src="$DEPLOY_DIR/examples/$f"
dst="$SRV_DIR/$f" dst="$SRV_DIR/$f"
if [ -f "$src" ]; then if [ -f "$src" ]; then
install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
fi fi
done done
# Step 5: Install systemd service unit. # Step 4: Install systemd units.
if [ -d "$SYSTEMD_DIR" ]; then if [ -d "$SYSTEMD_DIR" ]; then
info "Installing systemd service unit to $SYSTEMD_DIR" info "Installing systemd units to $SYSTEMD_DIR"
install -m 0644 -o root -g root "$SCRIPT_DIR/mcias.service" "$SYSTEMD_DIR/mcias.service" for unit in mcias.service mcias-backup.service mcias-backup.timer; do
src="$DEPLOY_DIR/systemd/$unit"
if [ -f "$src" ]; then
install -m 0644 -o root -g root "$src" "$SYSTEMD_DIR/$unit"
info " Installed $unit"
fi
done
info "Reloading systemd daemon" info "Reloading systemd daemon"
systemctl daemon-reload 2>/dev/null || warn "systemctl not available; reload manually." systemctl daemon-reload 2>/dev/null || warn "systemctl not available; reload manually."
info "Enabling backup timer"
systemctl enable mcias-backup.timer 2>/dev/null || warn "Could not enable timer; enable manually with: systemctl enable mcias-backup.timer"
else else
warn "systemd not found at $SYSTEMD_DIR; skipping service unit installation." warn "systemd not found at $SYSTEMD_DIR; skipping service unit installation."
fi fi
# Step 6: Install man pages. # Step 5: Install man pages.
if [ -d "$REPO_ROOT/man/man1" ]; then if [ -d "$REPO_ROOT/man/man1" ]; then
install -d -m 0755 -o root -g root "$MAN_DIR" install -d -m 0755 -o root -g root "$MAN_DIR"
info "Installing man pages to $MAN_DIR" info "Installing man pages to $MAN_DIR"
@@ -170,12 +177,12 @@ Next steps:
# Self-signed (development / personal use): # Self-signed (development / personal use):
openssl req -x509 -newkey ed25519 -days 3650 \\ openssl req -x509 -newkey ed25519 -days 3650 \\
-keyout /srv/mcias/server.key \\ -keyout /srv/mcias/certs/server.key \\
-out /srv/mcias/server.crt \\ -out /srv/mcias/certs/server.crt \\
-subj "/CN=auth.example.com" \\ -subj "/CN=auth.example.com" \\
-nodes -nodes
chmod 0640 /srv/mcias/server.key chmod 0640 /srv/mcias/certs/server.key
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt chown mcias:mcias /srv/mcias/certs/server.key /srv/mcias/certs/server.crt
2. Copy and edit the configuration file: 2. Copy and edit the configuration file:
@@ -200,6 +207,9 @@ Next steps:
systemctl start mcias systemctl start mcias
systemctl status mcias systemctl status mcias
The backup timer was enabled automatically. Verify with:
systemctl status mcias-backup.timer
5. Create the first admin account using mciasdb (while the server is 5. Create the first admin account using mciasdb (while the server is
running, or before first start): running, or before first start):

View File

@@ -0,0 +1,32 @@
[Unit]
Description=MCIAS Database Backup
Documentation=man:mciasdb(1)
After=mcias.service
# Backup runs against the live database using VACUUM INTO, which is safe
# while mciassrv is running (WAL mode allows concurrent readers).
[Service]
Type=oneshot
User=mcias
Group=mcias
EnvironmentFile=/srv/mcias/env
ExecStart=/usr/local/bin/mciasdb -config /srv/mcias/mcias.toml snapshot
# Filesystem restrictions (read-write to /srv/mcias for the backup output).
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/mcias
NoNewPrivileges=true
PrivateDevices=true
CapabilityBoundingSet=
RestrictSUIDSGID=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Daily MCIAS Database Backup
Documentation=man:mciasdb(1)
[Timer]
# Run daily at 02:00 UTC with up to 5-minute random jitter to avoid
# thundering-herd on systems with many services.
OnCalendar=*-*-* 02:00:00 UTC
RandomizedDelaySec=5min
# Run immediately on boot if the last scheduled run was missed
# (e.g. host was offline at 02:00).
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -12,7 +12,7 @@ Group=mcias
# Configuration and secrets. # Configuration and secrets.
# /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase> # /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase>
# See dist/mcias.env.example for the template. # See deploy/examples/mcias.env.example for the template.
EnvironmentFile=/srv/mcias/env EnvironmentFile=/srv/mcias/env
ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml
@@ -42,6 +42,7 @@ PrivateDevices=true
ProtectKernelTunables=true ProtectKernelTunables=true
ProtectKernelModules=true ProtectKernelModules=true
ProtectControlGroups=true ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true RestrictNamespaces=true
RestrictRealtime=true RestrictRealtime=true
LockPersonality=true LockPersonality=true

68
internal/db/snapshot.go Normal file
View File

@@ -0,0 +1,68 @@
package db
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// Snapshot creates a consistent backup of the database at destPath using
// SQLite's VACUUM INTO statement. VACUUM INTO acquires a read lock for the
// duration of the copy, which is safe while the server is running in WAL mode.
// The destination file is created by SQLite; the caller must ensure the parent
// directory exists.
func (db *DB) Snapshot(destPath string) error {
// VACUUM INTO is not supported on in-memory databases.
if strings.Contains(db.path, "mode=memory") {
return fmt.Errorf("db: snapshot not supported on in-memory databases")
}
if _, err := db.sql.Exec("VACUUM INTO ?", destPath); err != nil {
return fmt.Errorf("db: snapshot VACUUM INTO %q: %w", destPath, err)
}
return nil
}
// SnapshotDir creates a timestamped backup in dir and prunes backups older
// than retainDays days. dir is created with mode 0750 if it does not exist.
// The backup filename format is mcias-20060102-150405.db.
func (db *DB) SnapshotDir(dir string, retainDays int) (string, error) {
if err := os.MkdirAll(dir, 0750); err != nil {
return "", fmt.Errorf("db: create backup dir %q: %w", dir, err)
}
ts := time.Now().UTC().Format("20060102-150405")
dest := filepath.Join(dir, fmt.Sprintf("mcias-%s.db", ts))
if err := db.Snapshot(dest); err != nil {
return "", err
}
// Prune backups older than retainDays.
if retainDays > 0 {
cutoff := time.Now().UTC().AddDate(0, 0, -retainDays)
entries, err := os.ReadDir(dir)
if err != nil {
// Non-fatal: the backup was written; log pruning failure separately.
return dest, fmt.Errorf("db: list backup dir for pruning: %w", err)
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
continue
}
// Skip the file we just wrote.
if e.Name() == filepath.Base(dest) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
return dest, nil
}

View File

@@ -28,6 +28,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/kyle/mcias/internal/token"
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn" mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn"
) )
@@ -618,13 +619,37 @@ func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Reques
// Login succeeded: clear lockout counter. // Login succeeded: clear lockout counter.
_ = s.db.ClearLoginFailures(acct.ID) _ = s.db.ClearLoginFailures(acct.ID)
// Issue JWT. // Load roles for policy check and expiry decision.
roles, err := s.db.GetRoles(acct.ID) roles, err := s.db.GetRoles(acct.ID)
if err != nil { if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return return
} }
// Policy check: evaluate auth:login rules.
// WebAuthn login has no service context (no service_name or tags in the
// request body), so per-service deny rules won't fire. Account-level deny
// rules (e.g. deny a specific role from all auth:login actions) apply.
// This mirrors the policy gate in handleLogin so both auth paths are consistent.
//
// Security: policy is checked after credential verification so that a
// policy-denied login returns 403 (not 401), distinguishing a policy
// restriction from a bad credential without leaking account existence.
if s.polEng != nil {
input := policy.PolicyInput{
Subject: acct.UUID,
AccountType: string(acct.AccountType),
Roles: roles,
Action: policy.ActionLogin,
Resource: policy.Resource{},
}
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"policy_denied"}`)
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
return
}
}
expiry := s.cfg.DefaultExpiry() expiry := s.cfg.DefaultExpiry()
for _, role := range roles { for _, role := range roles {
if role == "admin" { if role == "admin" {

View File

@@ -436,6 +436,12 @@ type loginRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
TOTPCode string `json:"totp_code,omitempty"` TOTPCode string `json:"totp_code,omitempty"`
// ServiceName and Tags identify the calling service. MCIAS evaluates the
// auth:login policy with these as the resource context, enabling operators
// to restrict which roles/account-types may log into specific services.
// Clients populate these from their [mcias] config section.
ServiceName string `json:"service_name,omitempty"`
Tags []string `json:"tags,omitempty"`
} }
// loginResponse is the response body for a successful login. // loginResponse is the response body for a successful login.
@@ -546,13 +552,42 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Login succeeded: clear any outstanding failure counter. // Login succeeded: clear any outstanding failure counter.
_ = s.db.ClearLoginFailures(acct.ID) _ = s.db.ClearLoginFailures(acct.ID)
// Determine expiry. // Load roles for expiry decision and policy check.
expiry := s.cfg.DefaultExpiry()
roles, err := s.db.GetRoles(acct.ID) roles, err := s.db.GetRoles(acct.ID)
if err != nil { if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return return
} }
// Policy check: evaluate auth:login with the calling service's context.
// Operator rules can deny login based on role, account type, service name,
// or tags. The built-in default Allow for auth:login is overridden by any
// matching Deny rule (deny-wins semantics).
//
// Security: policy is checked after credential verification so that a
// policy-denied login returns 403 (not 401), distinguishing a service
// access restriction from a wrong password without leaking user existence.
{
input := policy.PolicyInput{
Subject: acct.UUID,
AccountType: string(acct.AccountType),
Roles: roles,
Action: policy.ActionLogin,
Resource: policy.Resource{
ServiceName: req.ServiceName,
Tags: req.Tags,
},
}
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil,
audit.JSON("reason", "policy_deny", "service_name", req.ServiceName))
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
return
}
}
// Determine expiry.
expiry := s.cfg.DefaultExpiry()
for _, r := range roles { for _, r := range roles {
if r == "admin" { if r == "admin" {
expiry = s.cfg.AdminExpiry() expiry = s.cfg.AdminExpiry()

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

@@ -567,6 +567,12 @@ paths:
If the account has TOTP enrolled, `totp_code` is required. If the account has TOTP enrolled, `totp_code` is required.
Omitting it returns HTTP 401 with code `totp_required`. Omitting it returns HTTP 401 with code `totp_required`.
`service_name` and `tags` identify the calling service. MCIAS
evaluates `auth:login` policy against these values after credentials
are verified. A policy-denied login returns HTTP 403 (not 401) so
callers can distinguish a service access restriction from bad credentials.
Clients should populate these from their `[mcias]` config section.
operationId: login operationId: login
tags: [Public] tags: [Public]
requestBody: requestBody:
@@ -587,6 +593,21 @@ paths:
type: string type: string
description: Current 6-digit TOTP code. Required if TOTP is enrolled. description: Current 6-digit TOTP code. Required if TOTP is enrolled.
example: "123456" example: "123456"
service_name:
type: string
description: >
Name of the calling service. Used by MCIAS to evaluate
auth:login policy rules that target specific services.
example: metatron
tags:
type: array
items:
type: string
description: >
Tags describing the calling service (e.g. "env:restricted").
MCIAS evaluates auth:login policy rules with required_tags
against this list.
example: ["env:restricted"]
responses: responses:
"200": "200":
description: Login successful. Returns JWT and expiry. description: Login successful. Returns JWT and expiry.
@@ -607,6 +628,17 @@ paths:
value: {error: invalid credentials, code: unauthorized} value: {error: invalid credentials, code: unauthorized}
totp_required: totp_required:
value: {error: TOTP code required, code: totp_required} value: {error: TOTP code required, code: totp_required}
"403":
description: >
Login denied by policy. Credentials were valid but an operator
policy rule blocks this account from accessing the calling service.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
examples:
policy_denied:
value: {error: access denied by policy, code: policy_denied}
"429": "429":
$ref: "#/components/responses/RateLimited" $ref: "#/components/responses/RateLimited"

View File

@@ -25,10 +25,17 @@
return bytes.buffer; return bytes.buffer;
} }
// Get the CSRF token from the cookie for mutating requests. // Get the CSRF token from the body's hx-headers attribute (HMAC header value).
// The cookie holds the nonce; the header holds the HMAC — they are different.
function getCSRFToken() { function getCSRFToken() {
var match = document.cookie.match(/(?:^|;\s*)mcias_csrf=([^;]+)/); try {
return match ? match[1] : ''; var hdr = document.body.getAttribute('hx-headers');
if (hdr) {
var parsed = JSON.parse(hdr);
if (parsed['X-CSRF-Token']) return parsed['X-CSRF-Token'];
}
} catch (e) { /* fall through */ }
return '';
} }
function showError(id, msg) { function showError(id, msg) {
@@ -199,10 +206,12 @@
if (loginBtn) { if (loginBtn) {
loginBtn.addEventListener('click', function () { loginBtn.addEventListener('click', function () {
hideError('webauthn-login-error'); hideError('webauthn-login-error');
var usernameInput = document.getElementById('username');
var username = usernameInput ? usernameInput.value.trim() : '';
loginBtn.disabled = true; loginBtn.disabled = true;
loginBtn.textContent = 'Waiting for authenticator...'; loginBtn.textContent = 'Waiting for authenticator...';
window.mciasWebAuthnLogin('', function () { window.mciasWebAuthnLogin(username, function () {
window.location.href = '/dashboard'; window.location.href = '/dashboard';
}, function (err) { }, function (err) {
loginBtn.disabled = false; loginBtn.disabled = false;

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">

View File

@@ -4,7 +4,7 @@
<div id="webauthn-enroll-success" class="alert alert-success" style="display:none" role="alert"></div> <div id="webauthn-enroll-success" class="alert alert-success" style="display:none" role="alert"></div>
<div class="form-group"> <div class="form-group">
<label for="webauthn-name">Passkey Name</label> <label for="webauthn-name">Passkey Name</label>
<input class="form-control" type="text" id="webauthn-name" placeholder="e.g. YubiKey 5" value="Passkey"> <input class="form-control" type="text" id="webauthn-name" placeholder="e.g. YubiKey 5, Touch ID" value="Passkey">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="webauthn-password">Current Password</label> <label for="webauthn-password">Current Password</label>

View File

@@ -10,12 +10,12 @@
</div> </div>
{{if .WebAuthnEnabled}} {{if .WebAuthnEnabled}}
<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 &amp; Security Keys</h2>
<p class="text-muted text-small" style="margin-bottom:.75rem"> <p class="text-muted text-small" style="margin-bottom:.75rem">
Passkeys let you sign in without a password using your device's biometrics or a security key. Register a passkey (Touch ID, Windows Hello) or a hardware security key (YubiKey, FIDO2) for passwordless sign-in or two-factor authentication.
</p> </p>
{{template "webauthn_credentials" .}} {{template "webauthn_credentials" .}}
<h3 style="font-size:.9rem;font-weight:600;margin:1rem 0 .5rem">Add a Passkey</h3> <h3 style="font-size:.9rem;font-weight:600;margin:1rem 0 .5rem">Add a Passkey or Security Key</h3>
{{template "webauthn_enroll" .}} {{template "webauthn_enroll" .}}
</div> </div>
<script src="/static/webauthn.js"></script> <script src="/static/webauthn.js"></script>