Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b5e1a7ed6 | |||
| e4220b840e | |||
| cff7276293 | |||
| be3bc807b7 | |||
| ead32f72f8 | |||
| d7d80c0f25 | |||
| 41d01edfb4 | |||
| 9b521f3d99 | |||
| 115f23a3ea | |||
| 35e96444aa | |||
| db7cd73a6e | |||
| 39d9ffb79a | |||
| b0afe3b993 | |||
| 446b3df52d | |||
| 0b37fde155 | |||
| 37afc68287 | |||
| 25417b24f4 | |||
|
|
19fa0c9a8e | ||
| 7db560dae4 | |||
| 124d0cdcd1 | |||
| cf1f4f94be | |||
| 52cc979814 | |||
| 8bf5c9033f | |||
| cb661bb8f5 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -20,7 +20,8 @@ mcias.toml
|
||||
*~
|
||||
go.work
|
||||
go.work.sum
|
||||
dist/mcias_*.tar.gz
|
||||
# dist/ is purely build output (tarballs); never commit it
|
||||
dist/
|
||||
man/man1/*.gz
|
||||
|
||||
# Client library build artifacts
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
CLAUDE.md
|
||||
@@ -0,0 +1,8 @@
|
||||
[2026-03-15 19:17] - Updated by Junie
|
||||
{
|
||||
"TYPE": "negative",
|
||||
"CATEGORY": "Service reliability",
|
||||
"EXPECTATION": "The Swagger docs endpoint should remain accessible and stable at all times.",
|
||||
"NEW INSTRUCTION": "WHEN swagger/docs endpoint is down or errors THEN Diagnose cause, apply fix, and restore availability immediately"
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"lang":"en","usageCount":1}]
|
||||
[{"lang":"en","usageCount":7}]
|
||||
301
ARCHITECTURE.md
301
ARCHITECTURE.md
@@ -15,16 +15,16 @@ parties that delegate authentication decisions to it.
|
||||
### Components
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ MCIAS Server (mciassrv) │
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ MCIAS Server (mciassrv) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
|
||||
│ │ Auth │ │ Token │ │ Account / Role │ │
|
||||
│ │ Handler │ │ Manager │ │ Manager │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘ │
|
||||
│ └─────────────┴─────────────────┘ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ ┌─────────▼──────────┐ │
|
||||
│ │ SQLite Database │ │
|
||||
│ │ SQLite Database │ │
|
||||
│ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
||||
@@ -32,10 +32,10 @@ parties that delegate authentication decisions to it.
|
||||
│ │ (net/http) │ │ (google.golang.org/ │ │
|
||||
│ │ :8443 │ │ grpc) :9443 │ │
|
||||
│ └──────────────────┘ └──────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
▲ ▲ ▲ ▲
|
||||
│ HTTPS/REST │ HTTPS/REST │ gRPC/TLS │ direct file I/O
|
||||
│ │ │ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
▲ ▲ ▲ ▲
|
||||
│ HTTPS/REST │ HTTPS/REST │ gRPC/TLS │ direct file I/O
|
||||
│ │ │ │
|
||||
┌────┴──────┐ ┌────┴─────┐ ┌─────┴────────┐ ┌───┴────────┐
|
||||
│ Personal │ │ mciasctl │ │ mciasgrpcctl │ │ mciasdb │
|
||||
│ Apps │ │ (admin │ │ (gRPC admin │ │ (DB tool) │
|
||||
@@ -128,7 +128,8 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
|
||||
**Human accounts** — interactive users. Can authenticate via:
|
||||
- Username + password (Argon2id hash stored in DB)
|
||||
- Optional TOTP (RFC 6238); if enrolled, required on every login
|
||||
- Future: FIDO2/WebAuthn, Yubikey (not in scope for v1)
|
||||
- Optional FIDO2/WebAuthn passkeys and security keys; discoverable credentials
|
||||
enable passwordless login, non-discoverable credentials serve as 2FA
|
||||
|
||||
**System accounts** — non-interactive service identities. Have:
|
||||
- A single active bearer token at a time (rotating the token revokes the old one)
|
||||
@@ -367,7 +368,25 @@ All endpoints use JSON request/response bodies. All responses include a
|
||||
| POST | `/v1/token/issue` | admin JWT | Issue service account token |
|
||||
| DELETE | `/v1/token/{jti}` | admin JWT | Revoke token by JTI |
|
||||
|
||||
### Account Endpoints (admin only)
|
||||
### Token Download Endpoint
|
||||
|
||||
| Method | Path | Auth required | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/token/download/{nonce}` | bearer JWT | Download a previously issued token via one-time nonce (5-min TTL, single-use) |
|
||||
|
||||
The token download flow issues a short-lived nonce when a service token is created
|
||||
via `POST /accounts/{id}/token`. The bearer must be authenticated; the nonce is
|
||||
deleted on first download to prevent replay. This avoids exposing the raw token
|
||||
value in an HTMX fragment or flash message.
|
||||
|
||||
### Token Delegation Endpoints (admin only)
|
||||
|
||||
| Method | Path | Auth required | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/accounts/{id}/token/delegates` | admin JWT | Grant a human account permission to issue tokens for a system account |
|
||||
| DELETE | `/accounts/{id}/token/delegates/{grantee}` | admin JWT | Revoke token-issue delegation |
|
||||
|
||||
### Account Endpoints
|
||||
|
||||
| Method | Path | Auth required | Description |
|
||||
|---|---|---|---|
|
||||
@@ -376,6 +395,7 @@ All endpoints use JSON request/response bodies. All responses include a
|
||||
| GET | `/v1/accounts/{id}` | admin JWT | Get account details |
|
||||
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
|
||||
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account |
|
||||
| POST | `/v1/accounts/{id}/token` | bearer JWT (admin or delegate) | Issue/rotate service account token |
|
||||
|
||||
### Password Endpoints
|
||||
|
||||
@@ -401,11 +421,23 @@ All endpoints use JSON request/response bodies. All responses include a
|
||||
| POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code |
|
||||
| DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) |
|
||||
|
||||
### WebAuthn Endpoints
|
||||
|
||||
| Method | Path | Auth required | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/v1/auth/webauthn/register/begin` | bearer JWT | Begin WebAuthn registration (requires password re-auth) |
|
||||
| POST | `/v1/auth/webauthn/register/finish` | bearer JWT | Complete WebAuthn registration |
|
||||
| POST | `/v1/auth/webauthn/login/begin` | none | Begin WebAuthn login (discoverable or username-scoped) |
|
||||
| POST | `/v1/auth/webauthn/login/finish` | none | Complete WebAuthn login, returns JWT |
|
||||
| GET | `/v1/accounts/{id}/webauthn` | admin JWT | List WebAuthn credential metadata |
|
||||
| DELETE | `/v1/accounts/{id}/webauthn/{credentialId}` | admin JWT | Remove WebAuthn credential |
|
||||
|
||||
### Postgres Credential Endpoints
|
||||
|
||||
| Method | Path | Auth required | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials |
|
||||
| GET | `/v1/pgcreds` | bearer JWT | List all credentials accessible to the caller (owned + explicitly granted) |
|
||||
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials for a specific account |
|
||||
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials |
|
||||
|
||||
### Tag Endpoints (admin only)
|
||||
@@ -479,6 +511,7 @@ cookie pattern (`mcias_csrf`).
|
||||
| `/policies` | Policy rules management — create, enable/disable, delete |
|
||||
| `/audit` | Audit log viewer |
|
||||
| `/profile` | User profile — self-service password change (any authenticated user) |
|
||||
| `/service-accounts` | Delegated service account list for non-admin users; issue/rotate token with one-time download |
|
||||
|
||||
**HTMX fragments:** Mutating operations (role updates, tag edits, credential
|
||||
saves, policy toggles, access grants) use HTMX partial-page updates for a
|
||||
@@ -658,6 +691,43 @@ CREATE TABLE policy_rules (
|
||||
not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339)
|
||||
expires_at TEXT DEFAULT NULL -- optional: expiry time (RFC3339)
|
||||
);
|
||||
|
||||
-- Token issuance delegation: tracks which human accounts may issue tokens for
|
||||
-- a given system account without holding the global admin role. Admins manage
|
||||
-- delegates; delegates can issue/rotate tokens for the specific system account
|
||||
-- only and cannot modify any other account settings.
|
||||
CREATE TABLE service_account_delegates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, -- target system account
|
||||
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, -- human account granted access
|
||||
granted_by INTEGER REFERENCES accounts(id), -- admin who granted access
|
||||
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
UNIQUE (account_id, grantee_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sa_delegates_account ON service_account_delegates (account_id);
|
||||
CREATE INDEX idx_sa_delegates_grantee ON service_account_delegates (grantee_id);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- WebAuthn credentials (migration 000009)
|
||||
CREATE TABLE webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
credential_id_enc BLOB NOT NULL,
|
||||
credential_id_nonce BLOB NOT NULL,
|
||||
public_key_enc BLOB NOT NULL,
|
||||
public_key_nonce BLOB NOT NULL,
|
||||
aaguid TEXT NOT NULL DEFAULT '',
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
discoverable INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
);
|
||||
CREATE INDEX idx_webauthn_credentials_account ON webauthn_credentials(account_id);
|
||||
```
|
||||
|
||||
### Schema Notes
|
||||
@@ -665,9 +735,10 @@ CREATE TABLE policy_rules (
|
||||
- Passwords are stored as PHC-format Argon2id strings (e.g.,
|
||||
`$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm
|
||||
parameters. Future parameter upgrades are transparent.
|
||||
- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a
|
||||
master key held only in server memory (derived at startup from a passphrase
|
||||
or keyfile). The nonce is stored adjacent to the ciphertext.
|
||||
- TOTP secrets, Postgres passwords, and WebAuthn credential IDs/public keys are
|
||||
encrypted with AES-256-GCM using a master key held only in server memory
|
||||
(derived at startup from a passphrase or keyfile). The nonce is stored
|
||||
adjacent to the ciphertext.
|
||||
- The master key salt is stored in `server_config.master_key_salt` so the
|
||||
Argon2id KDF produces the same key on every restart. Generated on first run.
|
||||
- The signing key encryption is layered: the Ed25519 private key is wrapped
|
||||
@@ -735,30 +806,45 @@ mcias/
|
||||
│ │ └── main.go
|
||||
│ ├── mciasctl/ # REST admin CLI
|
||||
│ │ └── main.go
|
||||
│ ├── mciasdb/ # direct SQLite maintenance tool (Phase 6)
|
||||
│ ├── mciasdb/ # direct SQLite maintenance tool
|
||||
│ │ └── main.go
|
||||
│ └── mciasgrpcctl/ # gRPC admin CLI companion (Phase 7)
|
||||
│ └── mciasgrpcctl/ # gRPC admin CLI companion
|
||||
│ └── main.go
|
||||
├── internal/
|
||||
│ ├── audit/ # audit log event detail marshaling
|
||||
│ ├── auth/ # login flow, TOTP verification, account lockout
|
||||
│ ├── config/ # config file parsing and validation
|
||||
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation
|
||||
│ ├── db/ # SQLite access layer (schema, migrations, queries)
|
||||
│ ├── grpcserver/ # gRPC handler implementations (Phase 7)
|
||||
│ │ └── migrations/ # numbered SQL migrations (currently 9)
|
||||
│ ├── grpcserver/ # gRPC handler implementations
|
||||
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy)
|
||||
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
|
||||
│ ├── policy/ # in-process authorization policy engine (§20)
|
||||
│ ├── server/ # HTTP handlers, router setup
|
||||
│ ├── token/ # JWT issuance, validation, revocation
|
||||
│ ├── ui/ # web UI context, CSRF, session, template handlers
|
||||
│ └── validate/ # input validation helpers (username, password strength)
|
||||
│ ├── validate/ # input validation helpers (username, password strength)
|
||||
│ ├── vault/ # master key lifecycle: seal/unseal state, key derivation
|
||||
│ └── webauthn/ # FIDO2/WebAuthn adapter (encrypt/decrypt credentials, user interface)
|
||||
├── web/
|
||||
│ ├── static/ # CSS and static assets
|
||||
│ └── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
||||
│ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build)
|
||||
│ ├── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
||||
│ └── embed.go # fs.FS embedding of static files and templates
|
||||
├── proto/
|
||||
│ └── mcias/v1/ # Protobuf service definitions (Phase 7)
|
||||
│ └── mcias/v1/ # Protobuf service definitions
|
||||
├── gen/
|
||||
│ └── mcias/v1/ # Generated Go stubs from protoc (committed; Phase 7)
|
||||
│ └── mcias/v1/ # Generated Go stubs from protoc (committed)
|
||||
├── clients/
|
||||
│ ├── go/ # Go client library
|
||||
│ ├── python/ # Python client library
|
||||
│ ├── rust/ # Rust client library
|
||||
│ └── lisp/ # Common Lisp client library
|
||||
├── test/
|
||||
│ ├── e2e/ # end-to-end test suite
|
||||
│ └── mock/ # Go mock server for client integration tests
|
||||
├── dist/ # operational artifacts: systemd unit, install script, config templates
|
||||
├── man/man1/ # man pages (mciassrv.1, mciasctl.1, mciasdb.1, mciasgrpcctl.1)
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
@@ -810,6 +896,8 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into
|
||||
| `policy_rule_updated` | Policy rule updated (priority, enabled, description) |
|
||||
| `policy_rule_deleted` | Policy rule deleted |
|
||||
| `policy_deny` | Policy engine denied a request (logged for every explicit deny) |
|
||||
| `token_delegate_granted` | Admin granted a human account permission to issue tokens for a system account |
|
||||
| `token_delegate_revoked` | Admin revoked a human account's token-issue delegation |
|
||||
| `vault_unsealed` | Vault unsealed via REST API or web UI; details include `source` (api\|ui) and `ip` |
|
||||
| `vault_sealed` | Vault sealed via REST API; details include actor ID, `source`, and `ip` |
|
||||
|
||||
@@ -970,7 +1058,8 @@ proto/
|
||||
└── v1/
|
||||
├── auth.proto # Login, Logout, Renew, TOTP enroll/confirm/remove
|
||||
├── token.proto # Validate, Issue, Revoke
|
||||
├── account.proto # CRUD for accounts and roles
|
||||
├── account.proto # CRUD for accounts, roles, and credentials
|
||||
├── policy.proto # Policy rule CRUD (PolicyService)
|
||||
├── admin.proto # Health, public-key retrieval
|
||||
└── common.proto # Shared message types (Error, Timestamp wrappers)
|
||||
|
||||
@@ -991,6 +1080,7 @@ in `proto/generate.go` using `protoc-gen-go` and `protoc-gen-go-grpc`.
|
||||
| `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` |
|
||||
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
|
||||
| `CredentialService` | `GetPGCreds`, `SetPGCreds` |
|
||||
| `PolicyService` | `ListPolicyRules`, `CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`, `DeletePolicyRule` |
|
||||
| `AdminService` | `Health`, `GetPublicKey` |
|
||||
|
||||
All request/response messages follow the same credential-exclusion rules as
|
||||
@@ -1203,8 +1293,9 @@ The Makefile `docker` target automates the build step with the version tag.
|
||||
| `generate` | `go generate ./...` (re-generates proto stubs) |
|
||||
| `man` | Build man pages; compress to `.gz` in `man/` |
|
||||
| `install` | Run `dist/install.sh` |
|
||||
| `docker` | `docker build -t mcias:$(VERSION) .` |
|
||||
| `clean` | Remove `bin/` and compressed man pages |
|
||||
| `docker` | `docker build -t mcias:$(VERSION) -t mcias:latest .` |
|
||||
| `docker-clean` | Remove local `mcias:$(VERSION)` and `mcias:latest` images; prune dangling images with the mcias label |
|
||||
| `clean` | Remove `bin/`, compressed man pages, and local Docker images |
|
||||
| `dist` | Cross-compile release tarballs for linux/amd64 and linux/arm64 |
|
||||
|
||||
### Upgrade Path
|
||||
@@ -1286,7 +1377,7 @@ Error types exposed by every library:
|
||||
|
||||
#### Go (`clients/go/`)
|
||||
|
||||
- Module: `git.wntrmute.dev/kyle/mcias/clients/go`
|
||||
- Module: `git.wntrmute.dev/mc/mcias/clients/go`
|
||||
- Package: `mciasgoclient`
|
||||
- HTTP: `net/http` with custom `*tls.Config` for CA cert
|
||||
- Token state: guarded by `sync.RWMutex`
|
||||
@@ -1373,6 +1464,8 @@ needed:
|
||||
|
||||
- A human account should be able to access credentials for one specific service
|
||||
without being a full admin.
|
||||
- A human account should be able to issue/rotate tokens for one specific service
|
||||
account without holding the global `admin` role (see token delegation, §21).
|
||||
- A system account (`deploy-agent`) should only operate on hosts tagged
|
||||
`env:staging`, not `env:production`.
|
||||
- A "secrets reader" role should read pgcreds for any service but change nothing.
|
||||
@@ -1465,7 +1558,7 @@ type Resource struct {
|
||||
// Rule is a single policy statement. All populated fields are ANDed.
|
||||
// A zero/empty field is a wildcard (matches anything).
|
||||
type Rule struct {
|
||||
ID int64 // database primary key; 0 for built-in rules
|
||||
ID int64 // database primary key; negative for built-in rules (-1 … -7)
|
||||
Description string
|
||||
|
||||
// Principal match conditions
|
||||
@@ -1681,3 +1774,157 @@ introduced.
|
||||
| `policy_rule_deleted` | Rule deleted |
|
||||
| `tag_added` | Tag added to an account |
|
||||
| `tag_removed` | Tag removed from an account |
|
||||
|
||||
---
|
||||
|
||||
## 21. Token Issuance Delegation
|
||||
|
||||
### Motivation
|
||||
|
||||
The initial design required the `admin` role to issue a service account token.
|
||||
This blocks a common workflow: a developer who owns one personal app (e.g.
|
||||
`payments-api`) wants to rotate its service token without granting another
|
||||
person full admin access to all of MCIAS.
|
||||
|
||||
Token issuance delegation solves this by allowing admins to grant specific
|
||||
human accounts the right to issue/rotate tokens for specific system accounts —
|
||||
and nothing else.
|
||||
|
||||
### Model
|
||||
|
||||
The `service_account_delegates` table stores the delegation relationship:
|
||||
|
||||
```
|
||||
service_account_delegates(account_id, grantee_id, granted_by, granted_at)
|
||||
```
|
||||
|
||||
- `account_id` — the **system account** whose token the delegate may issue
|
||||
- `grantee_id` — the **human account** granted the right
|
||||
- `granted_by` — the admin who created the grant (for audit purposes)
|
||||
|
||||
A human account is a delegate if a row exists with their ID as `grantee_id`.
|
||||
Delegates may:
|
||||
|
||||
- Issue/rotate the token for the specific system account
|
||||
- Download the newly issued token via the one-time nonce endpoint
|
||||
- View the system account on their `/service-accounts` page
|
||||
|
||||
Delegates may **not**:
|
||||
|
||||
- Modify roles, tags, or status on the system account
|
||||
- Read or modify pgcreds for the system account
|
||||
- List other accounts or perform any other admin operation
|
||||
|
||||
### Token Download Flow
|
||||
|
||||
Issuing a service token via `POST /accounts/{id}/token` (admin or delegate)
|
||||
stores the raw token string in an in-memory `sync.Map` under a random nonce
|
||||
with a 5-minute TTL. The handler returns the nonce in the HTMX fragment.
|
||||
|
||||
The caller redeems the nonce via `GET /token/download/{nonce}`, which:
|
||||
|
||||
1. Looks up the nonce in the map (missing → 404).
|
||||
2. Deletes the nonce immediately (prevents replay).
|
||||
3. Returns the token as `Content-Disposition: attachment; filename=token.txt`.
|
||||
|
||||
The nonce is not stored in the database and is lost on server restart. This
|
||||
is intentional: if the download window is missed, the operator simply issues
|
||||
a new token.
|
||||
|
||||
### Authorization Check
|
||||
|
||||
`POST /accounts/{id}/token` is authenticated (bearer JWT + CSRF) but not
|
||||
admin-only. The handler performs an explicit check:
|
||||
|
||||
```
|
||||
if claims.HasRole("admin") OR db.HasTokenIssueAccess(targetID, callerID):
|
||||
proceed
|
||||
else:
|
||||
403 Forbidden
|
||||
```
|
||||
|
||||
This check is done in the handler rather than middleware because the
|
||||
delegation relationship requires a DB lookup that depends on the caller's
|
||||
identity and the specific target account.
|
||||
|
||||
### Admin Management
|
||||
|
||||
| Endpoint | Description |
|
||||
|---|---|
|
||||
| `POST /accounts/{id}/token/delegates` | Grant delegation (admin only) |
|
||||
| `DELETE /accounts/{id}/token/delegates/{grantee}` | Revoke delegation (admin only) |
|
||||
|
||||
Both operations produce audit events (`token_delegate_granted`,
|
||||
`token_delegate_revoked`) and are visible in the account detail UI under
|
||||
the "Token Issue Access" section.
|
||||
|
||||
### Audit Events
|
||||
|
||||
| Event | Trigger |
|
||||
|---|---|
|
||||
| `token_delegate_granted` | Admin granted a human account token-issue access for a system account |
|
||||
| `token_delegate_revoked` | Admin revoked token-issue delegation |
|
||||
| `token_issued` | Token issued (existing event, also fires for delegate-issued tokens) |
|
||||
|
||||
## 22. FIDO2/WebAuthn Authentication
|
||||
|
||||
### Overview
|
||||
|
||||
WebAuthn support enables two credential modes:
|
||||
- **Discoverable credentials (passkeys)** — passwordless login. The authenticator
|
||||
stores a resident credential; the user clicks "Sign in with passkey" and the
|
||||
browser prompts for the credential directly.
|
||||
- **Non-discoverable credentials (security keys)** — 2FA alongside
|
||||
username+password. The server supplies allowCredentials for the account.
|
||||
|
||||
Either WebAuthn or TOTP satisfies the 2FA requirement. If both are enrolled the
|
||||
UI offers passkey first.
|
||||
|
||||
### Credential Storage
|
||||
|
||||
Credential IDs and public keys are encrypted at rest with AES-256-GCM using
|
||||
the vault master key, consistent with TOTP secrets and PG credentials. The
|
||||
nonce is stored alongside the ciphertext in the `webauthn_credentials` table.
|
||||
|
||||
Metadata (name, AAGUID, sign count, discoverable flag, transports, timestamps)
|
||||
is stored in plaintext for display and management.
|
||||
|
||||
### Challenge (Ceremony) Management
|
||||
|
||||
Registration and login ceremonies use an in-memory `sync.Map` with 120-second
|
||||
TTL, consistent with the `pendingLogins` and `tokenDownloads` patterns. Each
|
||||
ceremony is keyed by a 128-bit random nonce. Ceremonies are single-use:
|
||||
consumed on finish, expired entries cleaned by a background goroutine.
|
||||
|
||||
Separate ceremony stores exist for REST API (`internal/server`) and web UI
|
||||
(`internal/ui`) to maintain independent lifecycle management.
|
||||
|
||||
### Sign Counter Validation
|
||||
|
||||
On each assertion the stored sign counter is compared to the authenticator's
|
||||
reported value. If the reported counter is less than or equal to the stored
|
||||
counter (and both are non-zero), the assertion is rejected as a potential
|
||||
cloned authenticator. This mirrors the TOTP replay protection pattern.
|
||||
|
||||
### Audit Events
|
||||
|
||||
| Event | Description |
|
||||
|---|---|
|
||||
| `webauthn_enrolled` | New WebAuthn credential registered |
|
||||
| `webauthn_removed` | WebAuthn credential removed (self-service or admin) |
|
||||
| `webauthn_login_ok` | Successful WebAuthn authentication |
|
||||
| `webauthn_login_fail` | Failed WebAuthn authentication attempt |
|
||||
|
||||
### Configuration
|
||||
|
||||
WebAuthn is enabled by adding a `[webauthn]` section to the TOML config:
|
||||
|
||||
```toml
|
||||
[webauthn]
|
||||
rp_id = "mcias.metacircular.net"
|
||||
rp_origin = "https://mcias.metacircular.net:8443"
|
||||
display_name = "MCIAS"
|
||||
```
|
||||
|
||||
If the section is omitted, WebAuthn endpoints return 404 and the UI hides
|
||||
passkey-related controls.
|
||||
|
||||
62
Dockerfile
62
Dockerfile
@@ -1,12 +1,16 @@
|
||||
# Dockerfile — MCIAS multi-stage container image
|
||||
#
|
||||
# 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:
|
||||
# - Runs as non-root uid 10001 (mcias)
|
||||
# - 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
|
||||
#
|
||||
# Build:
|
||||
@@ -15,8 +19,7 @@
|
||||
# Run:
|
||||
# docker run -d \
|
||||
# --name mcias \
|
||||
# -v /path/to/config:/etc/mcias:ro \
|
||||
# -v mcias-data:/data \
|
||||
# -v /srv/mcias:/srv/mcias \
|
||||
# -e MCIAS_MASTER_PASSPHRASE=your-passphrase \
|
||||
# -p 8443:8443 \
|
||||
# -p 9443:9443 \
|
||||
@@ -25,7 +28,7 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 — builder
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM golang:1.26-bookworm AS builder
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -36,35 +39,29 @@ RUN go mod download
|
||||
# Copy source.
|
||||
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.
|
||||
# -ldflags="-s -w" strips the DWARF debug info and symbol table to reduce
|
||||
# image size.
|
||||
RUN CGO_ENABLED=1 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=1 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
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasctl ./cmd/mciasctl && \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasdb ./cmd/mciasdb && \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 — runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM debian:bookworm-slim
|
||||
FROM alpine:3.21
|
||||
|
||||
# Install runtime dependencies.
|
||||
# ca-certificates: required to validate external TLS certificates.
|
||||
# libc6: required by CGo-compiled binaries (sqlite).
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
libc6 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
# Create a non-root user for the service.
|
||||
# uid/gid 10001 is chosen to be well above the range typically assigned to
|
||||
# system users (1–999) and human users (1000+), reducing the chance of
|
||||
# collision with existing uids on the host when using host networking.
|
||||
RUN groupadd --gid 10001 mcias && \
|
||||
useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin mcias
|
||||
RUN addgroup -g 10001 mcias && \
|
||||
adduser -u 10001 -G mcias -H -s /sbin/nologin -D mcias
|
||||
|
||||
# Copy compiled binaries from the builder stage.
|
||||
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/mciasgrpcctl /usr/local/bin/mciasgrpcctl
|
||||
|
||||
# Create the config and data directories.
|
||||
# /etc/mcias is mounted read-only by the operator with the config file,
|
||||
# TLS cert, and TLS key.
|
||||
# /data is the SQLite database mount point.
|
||||
RUN mkdir -p /etc/mcias /data && \
|
||||
chown mcias:mcias /data && \
|
||||
chmod 0750 /data
|
||||
# Create the data directory.
|
||||
# /srv/mcias is mounted from the host with config, TLS certs, and database.
|
||||
RUN mkdir -p /srv/mcias/certs /srv/mcias/backups && \
|
||||
chown -R mcias:mcias /srv/mcias && \
|
||||
chmod 0750 /srv/mcias
|
||||
|
||||
# Declare /data as a volume so the operator must explicitly mount it.
|
||||
# The SQLite database must persist across container restarts.
|
||||
VOLUME /data
|
||||
# Declare /srv/mcias as a volume so the operator must explicitly mount it.
|
||||
# Contains the config file, TLS cert/key, and SQLite database.
|
||||
VOLUME /srv/mcias
|
||||
|
||||
# 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.
|
||||
@@ -93,7 +88,8 @@ EXPOSE 9443
|
||||
USER mcias
|
||||
|
||||
# Default entry point and config path.
|
||||
# The operator mounts /etc/mcias/mcias.conf from the host or a volume.
|
||||
# See dist/mcias.conf.docker.example for a suitable template.
|
||||
# The operator mounts /srv/mcias from the host containing mcias.toml,
|
||||
# TLS cert/key, and the SQLite database.
|
||||
# See deploy/examples/mcias.conf.docker.example for a suitable template.
|
||||
ENTRYPOINT ["mciassrv"]
|
||||
CMD ["-config", "/etc/mcias/mcias.conf"]
|
||||
CMD ["-config", "/srv/mcias/mcias.toml"]
|
||||
|
||||
@@ -381,7 +381,7 @@ expose the same API surface:
|
||||
|
||||
| Language | Location | Install |
|
||||
|----------|----------|---------|
|
||||
| Go | `clients/go/` | `go get git.wntrmute.dev/kyle/mcias/clients/go` |
|
||||
| Go | `clients/go/` | `go get git.wntrmute.dev/mc/mcias/clients/go` |
|
||||
| Python | `clients/python/` | `pip install ./clients/python` |
|
||||
| Rust | `clients/rust/` | `cargo add mcias-client` |
|
||||
| Common Lisp | `clients/lisp/` | ASDF `mcias-client` |
|
||||
@@ -389,7 +389,7 @@ expose the same API surface:
|
||||
### Go
|
||||
|
||||
```go
|
||||
import mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
||||
import mcias "git.wntrmute.dev/mc/mcias/clients/go"
|
||||
|
||||
c, err := mcias.New("https://auth.example.com:8443", "/etc/mcias/server.crt", "")
|
||||
if err != nil { ... }
|
||||
|
||||
98
Makefile
98
Makefile
@@ -3,18 +3,24 @@
|
||||
# Usage:
|
||||
# make build — compile all binaries to bin/
|
||||
# make test — run tests with race detector
|
||||
# make vet — run go vet
|
||||
# make lint — run golangci-lint
|
||||
# make all — vet → lint → test → build (CI pipeline)
|
||||
# make generate — regenerate protobuf stubs (requires protoc)
|
||||
# make proto-lint — lint proto files with buf
|
||||
# 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 dist — build release tarballs for linux/amd64 and linux/arm64
|
||||
# make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest
|
||||
# make docker-clean — remove local mcias Docker images
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variables
|
||||
# ---------------------------------------------------------------------------
|
||||
MODULE := git.wntrmute.dev/kyle/mcias
|
||||
MODULE := git.wntrmute.dev/mc/mcias
|
||||
MCR := mcr.svc.mcp.metacircular.net:8443
|
||||
BINARIES := mciassrv mciasctl mciasdb mciasgrpcctl
|
||||
BIN_DIR := bin
|
||||
MAN_DIR := man/man1
|
||||
@@ -26,20 +32,25 @@ MAN_PAGES := $(MAN_DIR)/mciassrv.1 $(MAN_DIR)/mciasctl.1 \
|
||||
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
|
||||
# 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
|
||||
GOFLAGS := -trimpath
|
||||
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.
|
||||
DIST_PLATFORMS := linux/amd64 linux/arm64
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default target
|
||||
# Default target — CI pipeline: vet → lint → test → build
|
||||
# ---------------------------------------------------------------------------
|
||||
.PHONY: all
|
||||
all: build
|
||||
all: vet lint test build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build — compile all binaries to bin/
|
||||
@@ -58,7 +69,14 @@ build:
|
||||
# ---------------------------------------------------------------------------
|
||||
.PHONY: 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
|
||||
@@ -67,6 +85,15 @@ test:
|
||||
lint:
|
||||
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
|
||||
# Requires: protoc, protoc-gen-go, protoc-gen-go-grpc
|
||||
@@ -75,6 +102,13 @@ lint:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -89,7 +123,7 @@ man: $(patsubst %.1,%.1.gz,$(MAN_PAGES))
|
||||
# ---------------------------------------------------------------------------
|
||||
.PHONY: install
|
||||
install: build
|
||||
sh dist/install.sh
|
||||
sh deploy/scripts/install.sh
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clean — remove build artifacts
|
||||
@@ -97,14 +131,16 @@ install: build
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf $(BIN_DIR)
|
||||
rm -rf dist/
|
||||
rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES))
|
||||
-docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dist — cross-compiled release tarballs for linux/amd64 and linux/arm64
|
||||
#
|
||||
# Output files: dist/mcias_<version>_<os>_<arch>.tar.gz
|
||||
# Each tarball contains: mciassrv, mciasctl, mciasdb, mciasgrpcctl,
|
||||
# man pages, and dist/ files.
|
||||
# man pages, and deploy/ files.
|
||||
# ---------------------------------------------------------------------------
|
||||
.PHONY: dist
|
||||
dist: man
|
||||
@@ -115,14 +151,12 @@ dist: man
|
||||
echo " DIST $$platform -> $$outdir.tar.gz"; \
|
||||
mkdir -p $$outdir/bin; \
|
||||
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)" \
|
||||
-o $$outdir/bin/$$bin ./cmd/$$bin; \
|
||||
done; \
|
||||
cp -r man $$outdir/; \
|
||||
cp dist/mcias.conf.example dist/mcias-dev.conf.example \
|
||||
dist/mcias.env.example dist/mcias.service \
|
||||
dist/install.sh $$outdir/; \
|
||||
cp -r deploy $$outdir/; \
|
||||
tar -czf $$outdir.tar.gz -C dist mcias_$$(echo $(VERSION) | tr -d 'v')_$${os}_$${arch}; \
|
||||
rm -rf $$outdir; \
|
||||
done
|
||||
@@ -130,9 +164,20 @@ dist: man
|
||||
# ---------------------------------------------------------------------------
|
||||
# docker — build the Docker image
|
||||
# ---------------------------------------------------------------------------
|
||||
.PHONY: docker
|
||||
.PHONY: docker push
|
||||
docker:
|
||||
docker build -t mcias:$(VERSION) -t mcias:latest .
|
||||
docker build --force-rm -t $(MCR)/mcias:$(VERSION) .
|
||||
|
||||
push: docker
|
||||
docker push $(MCR)/mcias:$(VERSION)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# docker-clean — remove local mcias Docker images
|
||||
# ---------------------------------------------------------------------------
|
||||
.PHONY: docker-clean
|
||||
docker-clean:
|
||||
-docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true
|
||||
-docker image prune -f --filter label=org.opencontainers.image.title=mcias 2>/dev/null || true
|
||||
|
||||
.PHONY: install-local
|
||||
install-local: build
|
||||
@@ -144,12 +189,17 @@ install-local: build
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " build Compile all binaries to bin/"
|
||||
@echo " test Run tests with race detector"
|
||||
@echo " lint Run golangci-lint"
|
||||
@echo " generate Regenerate protobuf stubs"
|
||||
@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 " all vet → lint → test → build (CI pipeline)"
|
||||
@echo " build Compile all binaries to bin/"
|
||||
@echo " test Run tests with race detector"
|
||||
@echo " vet Run go vet"
|
||||
@echo " lint Run golangci-lint"
|
||||
@echo " proto-lint Lint proto files with buf"
|
||||
@echo " generate Regenerate protobuf stubs"
|
||||
@echo " devserver Build and run mciassrv against run/ config"
|
||||
@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"
|
||||
|
||||
514
POLICY.md
Normal file
514
POLICY.md
Normal file
@@ -0,0 +1,514 @@
|
||||
# MCIAS Policy Engine
|
||||
|
||||
Reference guide for the MCIAS attribute-based access control (ABAC) policy
|
||||
engine. Covers concepts, rule authoring, the full action/resource catalogue,
|
||||
built-in defaults, time-scoped rules, and worked examples.
|
||||
|
||||
For the authoritative design rationale and middleware integration details see
|
||||
[ARCHITECTURE.md §20](ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Concepts
|
||||
|
||||
### Evaluation model
|
||||
|
||||
The policy engine is a **pure function**: given a `PolicyInput` (assembled from
|
||||
JWT claims and database lookups) and a slice of `Rule` values, it returns an
|
||||
`Effect` (`allow` or `deny`) and a pointer to the matching rule.
|
||||
|
||||
Evaluation proceeds in three steps:
|
||||
|
||||
1. **Sort** all rules (built-in defaults + operator rules) by `Priority`
|
||||
ascending. Lower number = evaluated first. Stable sort preserves insertion
|
||||
order within the same priority.
|
||||
2. **Deny-wins**: the first matching `deny` rule terminates evaluation
|
||||
immediately and returns `Deny`.
|
||||
3. **First-allow**: if no `deny` matched, the first matching `allow` rule
|
||||
returns `Allow`.
|
||||
4. **Default-deny**: if no rule matched at all, the request is denied.
|
||||
|
||||
The engine never touches the database. The caller (middleware) is responsible
|
||||
for assembling `PolicyInput` from JWT claims and DB lookups before calling
|
||||
`engine.Evaluate`.
|
||||
|
||||
### Rule matching
|
||||
|
||||
A rule matches a request when **every populated field** satisfies its
|
||||
condition. An empty/zero field is a wildcard (matches anything).
|
||||
|
||||
| Rule field | Match condition |
|
||||
|---|---|
|
||||
| `roles` | Principal holds **at least one** of the listed roles |
|
||||
| `account_types` | Principal's account type is in the list (`"human"`, `"system"`) |
|
||||
| `subject_uuid` | Principal UUID equals this value exactly |
|
||||
| `actions` | Request action is in the list |
|
||||
| `resource_type` | Target resource type equals this value |
|
||||
| `owner_matches_subject` | (if `true`) resource owner UUID equals the principal UUID |
|
||||
| `service_names` | Target service account username is in the list |
|
||||
| `required_tags` | Target account carries **all** of the listed tags |
|
||||
|
||||
All conditions are AND-ed. To express OR across principals or resources, create
|
||||
multiple rules.
|
||||
|
||||
### Priority
|
||||
|
||||
| Range | Intended use |
|
||||
|---|---|
|
||||
| 0 | Built-in defaults (compiled in; cannot be overridden via API) |
|
||||
| 1–49 | High-precedence operator deny rules (explicit blocks) |
|
||||
| 50–99 | Normal operator allow rules |
|
||||
| 100 | Default for new rules created via API or CLI |
|
||||
| 101+ | Low-precedence fallback rules |
|
||||
|
||||
Because deny-wins applies within the matched set (not just within a priority
|
||||
band), a `deny` rule at priority 100 still overrides an `allow` at priority 50
|
||||
if both match. Use explicit deny rules at low priority numbers (e.g. 10) when
|
||||
you want them to fire before any allow can be considered.
|
||||
|
||||
### Built-in default rules
|
||||
|
||||
These rules are compiled into the binary (`internal/policy/defaults.go`). They
|
||||
have IDs -1 through -7, priority 0, and **cannot be disabled or deleted via
|
||||
the API**. They reproduce the previous binary admin/non-admin behavior exactly.
|
||||
|
||||
| ID | Description | Conditions | Effect |
|
||||
|---|---|---|---|
|
||||
| -1 | Admin wildcard | `roles=[admin]` | allow |
|
||||
| -2 | Self-service logout / token renewal | `actions=[auth:logout, tokens:renew]` | allow |
|
||||
| -3 | Self-service TOTP enrollment | `actions=[totp:enroll]` | allow |
|
||||
| -7 | Self-service password change | `account_types=[human]`, `actions=[auth:change_password]` | allow |
|
||||
| -4 | System account reads own pgcreds | `account_types=[system]`, `actions=[pgcreds:read]`, `resource_type=pgcreds`, `owner_matches_subject=true` | allow |
|
||||
| -5 | System account issues/renews own token | `account_types=[system]`, `actions=[tokens:issue, tokens:renew]`, `resource_type=token`, `owner_matches_subject=true` | allow |
|
||||
| -6 | Public endpoints | `actions=[tokens:validate, auth:login]` | allow |
|
||||
|
||||
Custom operator rules extend this baseline; they do not replace it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Actions and Resource Types
|
||||
|
||||
### Actions
|
||||
|
||||
Actions follow the `resource:verb` convention. Use the exact string values
|
||||
shown below when authoring rules.
|
||||
|
||||
| Action string | Description | Notes |
|
||||
|---|---|---|
|
||||
| `accounts:list` | List all accounts | admin |
|
||||
| `accounts:create` | Create an account | admin |
|
||||
| `accounts:read` | Read account details | admin |
|
||||
| `accounts:update` | Update account (status, etc.) | admin |
|
||||
| `accounts:delete` | Soft-delete an account | admin |
|
||||
| `roles:read` | Read role assignments | admin |
|
||||
| `roles:write` | Grant or revoke roles | admin |
|
||||
| `tags:read` | Read account tags | admin |
|
||||
| `tags:write` | Set account tags | admin |
|
||||
| `tokens:issue` | Issue or rotate a service token | admin or delegate |
|
||||
| `tokens:revoke` | Revoke a token | admin |
|
||||
| `tokens:validate` | Validate a token | public |
|
||||
| `tokens:renew` | Renew own token | self-service |
|
||||
| `pgcreds:read` | Read Postgres credentials | admin or delegated |
|
||||
| `pgcreds:write` | Set Postgres credentials | admin |
|
||||
| `audit:read` | Read audit log | admin |
|
||||
| `totp:enroll` | Enroll TOTP | self-service |
|
||||
| `totp:remove` | Remove TOTP from an account | admin |
|
||||
| `auth:login` | Authenticate (username + password) | public |
|
||||
| `auth:logout` | Invalidate own session token | self-service |
|
||||
| `auth:change_password` | Change own password | self-service |
|
||||
| `policy:list` | List policy rules | admin |
|
||||
| `policy:manage` | Create, update, or delete policy rules | admin |
|
||||
|
||||
### Resource types
|
||||
|
||||
| Resource type string | Description |
|
||||
|---|---|
|
||||
| `account` | A human or system account record |
|
||||
| `token` | A JWT or service bearer token |
|
||||
| `pgcreds` | A Postgres credential record |
|
||||
| `audit_log` | The audit event log |
|
||||
| `totp` | A TOTP enrollment record |
|
||||
| `policy` | A policy rule record |
|
||||
|
||||
---
|
||||
|
||||
## 3. Rule Schema
|
||||
|
||||
Rules are stored in the `policy_rules` table. The `rule_json` column holds a
|
||||
JSON-encoded `RuleBody`. All other fields are dedicated columns.
|
||||
|
||||
### Database columns
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | Auto-assigned |
|
||||
| `priority` | INTEGER | Default 100; lower = evaluated first |
|
||||
| `description` | TEXT | Human-readable label (required) |
|
||||
| `enabled` | BOOLEAN | Disabled rules are excluded from the cache |
|
||||
| `not_before` | DATETIME (nullable) | Rule inactive before this UTC timestamp |
|
||||
| `expires_at` | DATETIME (nullable) | Rule inactive at and after this UTC timestamp |
|
||||
| `rule_json` | TEXT | JSON-encoded `RuleBody` (see below) |
|
||||
|
||||
### RuleBody JSON fields
|
||||
|
||||
```json
|
||||
{
|
||||
"effect": "allow" | "deny",
|
||||
"roles": ["role1", "role2"],
|
||||
"account_types": ["human"] | ["system"] | ["human", "system"],
|
||||
"subject_uuid": "<UUID string>",
|
||||
"actions": ["action:verb", ...],
|
||||
"resource_type": "<resource type string>",
|
||||
"owner_matches_subject": true | false,
|
||||
"service_names": ["svc-username", ...],
|
||||
"required_tags": ["tag:value", ...]
|
||||
}
|
||||
```
|
||||
|
||||
All fields are optional except `effect`. Omitted fields are wildcards.
|
||||
|
||||
---
|
||||
|
||||
## 4. Managing Rules
|
||||
|
||||
### Via mciasctl
|
||||
|
||||
```sh
|
||||
# List all rules
|
||||
mciasctl policy list
|
||||
|
||||
# Create a rule from a JSON file
|
||||
mciasctl policy create -description "My rule" -json rule.json
|
||||
|
||||
# Create a time-scoped rule
|
||||
mciasctl policy create \
|
||||
-description "Temp production access" \
|
||||
-json rule.json \
|
||||
-not-before 2026-04-01T00:00:00Z \
|
||||
-expires-at 2026-04-01T04:00:00Z
|
||||
|
||||
# Enable or disable a rule
|
||||
mciasctl policy update -id 7 -enabled=false
|
||||
|
||||
# Delete a rule
|
||||
mciasctl policy delete -id 7
|
||||
```
|
||||
|
||||
### Via REST API (admin JWT required)
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/v1/policy/rules` | List all rules |
|
||||
| POST | `/v1/policy/rules` | Create a rule |
|
||||
| GET | `/v1/policy/rules/{id}` | Get a single rule |
|
||||
| PATCH | `/v1/policy/rules/{id}` | Update priority, enabled, or description |
|
||||
| DELETE | `/v1/policy/rules/{id}` | Delete a rule |
|
||||
|
||||
### Via Web UI
|
||||
|
||||
The `/policies` page lists all rules with enable/disable toggles and a create
|
||||
form. Mutating operations use HTMX partial-page updates.
|
||||
|
||||
### Cache reload
|
||||
|
||||
The `Engine` caches the active rule set in memory. It reloads automatically
|
||||
after any `policy_rule_*` admin event. To force a reload without a rule change,
|
||||
send `SIGHUP` to `mciassrv`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Account Tags
|
||||
|
||||
Tags are key:value strings attached to accounts (human or system) and used as
|
||||
resource match conditions in rules. They are stored in the `account_tags` table.
|
||||
|
||||
### Recommended tag conventions
|
||||
|
||||
| Tag | Meaning |
|
||||
|---|---|
|
||||
| `env:production` | Account belongs to the production environment |
|
||||
| `env:staging` | Account belongs to the staging environment |
|
||||
| `env:dev` | Account belongs to the development environment |
|
||||
| `svc:payments-api` | Account is associated with the payments-api service |
|
||||
| `machine:db-west-01` | Account is associated with a specific host |
|
||||
| `team:platform` | Account is owned by the platform team |
|
||||
|
||||
Tag names are not enforced by the schema; the conventions above are
|
||||
recommendations only.
|
||||
|
||||
### Managing tags
|
||||
|
||||
```sh
|
||||
# Set tags on an account (replaces the full tag set atomically)
|
||||
mciasctl accounts update -id <uuid> -tags "env:staging,svc:payments-api"
|
||||
|
||||
# Via REST (admin JWT)
|
||||
PUT /v1/accounts/{id}/tags
|
||||
Content-Type: application/json
|
||||
["env:staging", "svc:payments-api"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Worked Examples
|
||||
|
||||
### Example A — Named service delegation
|
||||
|
||||
**Goal:** Alice needs to read Postgres credentials for `payments-api` only.
|
||||
|
||||
1. Grant Alice the role `svc:payments-api`:
|
||||
|
||||
```sh
|
||||
mciasctl accounts roles grant -id <alice-uuid> -role svc:payments-api
|
||||
```
|
||||
|
||||
2. Create the allow rule (`rule.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"effect": "allow",
|
||||
"roles": ["svc:payments-api"],
|
||||
"actions": ["pgcreds:read"],
|
||||
"resource_type": "pgcreds",
|
||||
"service_names": ["payments-api"]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
mciasctl policy create -description "Alice: read payments-api pgcreds" \
|
||||
-json rule.json -priority 50
|
||||
```
|
||||
|
||||
When Alice calls `GET /v1/accounts/{payments-api-uuid}/pgcreds`, the middleware
|
||||
sets `resource.ServiceName = "payments-api"`. The rule matches and access is
|
||||
granted. A call against any other service account sets a different
|
||||
`ServiceName`; no rule matches and default-deny applies.
|
||||
|
||||
---
|
||||
|
||||
### Example B — Machine-tag gating (staging only)
|
||||
|
||||
**Goal:** `deploy-agent` may read pgcreds for staging accounts but must be
|
||||
explicitly blocked from production.
|
||||
|
||||
1. Tag all staging system accounts:
|
||||
|
||||
```sh
|
||||
mciasctl accounts update -id <svc-uuid> -tags "env:staging"
|
||||
```
|
||||
|
||||
2. Explicit deny for production (low priority number = evaluated first):
|
||||
|
||||
```json
|
||||
{
|
||||
"effect": "deny",
|
||||
"subject_uuid": "<deploy-agent-uuid>",
|
||||
"resource_type": "pgcreds",
|
||||
"required_tags": ["env:production"]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
mciasctl policy create -description "deploy-agent: deny production pgcreds" \
|
||||
-json deny.json -priority 10
|
||||
```
|
||||
|
||||
3. Allow for staging:
|
||||
|
||||
```json
|
||||
{
|
||||
"effect": "allow",
|
||||
"subject_uuid": "<deploy-agent-uuid>",
|
||||
"actions": ["pgcreds:read"],
|
||||
"resource_type": "pgcreds",
|
||||
"required_tags": ["env:staging"]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
mciasctl policy create -description "deploy-agent: allow staging pgcreds" \
|
||||
-json allow.json -priority 50
|
||||
```
|
||||
|
||||
The deny rule (priority 10) fires before the allow rule (priority 50) for any
|
||||
production-tagged resource. For staging resources the deny does not match and
|
||||
the allow rule permits access.
|
||||
|
||||
---
|
||||
|
||||
### Example C — Blanket "secrets reader" role
|
||||
|
||||
**Goal:** Any account holding the `secrets-reader` role may read pgcreds for
|
||||
any service.
|
||||
|
||||
```json
|
||||
{
|
||||
"effect": "allow",
|
||||
"roles": ["secrets-reader"],
|
||||
"actions": ["pgcreds:read"],
|
||||
"resource_type": "pgcreds"
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
mciasctl policy create -description "secrets-reader: read any pgcreds" \
|
||||
-json rule.json -priority 50
|
||||
```
|
||||
|
||||
No `service_names` or `required_tags` means the rule matches any target
|
||||
account. Grant the role to any account that needs broad read access:
|
||||
|
||||
```sh
|
||||
mciasctl accounts roles grant -id <uuid> -role secrets-reader
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example D — Time-scoped emergency access
|
||||
|
||||
**Goal:** `deploy-agent` needs temporary access to production pgcreds for a
|
||||
4-hour maintenance window on 2026-04-01.
|
||||
|
||||
```json
|
||||
{
|
||||
"effect": "allow",
|
||||
"subject_uuid": "<deploy-agent-uuid>",
|
||||
"actions": ["pgcreds:read"],
|
||||
"resource_type": "pgcreds",
|
||||
"required_tags": ["env:production"]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
mciasctl policy create \
|
||||
-description "deploy-agent: temp production access (maintenance window)" \
|
||||
-json rule.json \
|
||||
-priority 50 \
|
||||
-not-before 2026-04-01T02:00:00Z \
|
||||
-expires-at 2026-04-01T06:00:00Z
|
||||
```
|
||||
|
||||
The engine excludes this rule from the cache before `not_before` and after
|
||||
`expires_at`. No manual cleanup is required; the rule becomes inert
|
||||
automatically. Both fields are nullable — omitting either means no constraint
|
||||
on that end.
|
||||
|
||||
---
|
||||
|
||||
### Example E — Per-account subject rule
|
||||
|
||||
**Goal:** Bob (a contractor) may issue/rotate the token for `worker-bot` only,
|
||||
without any admin role.
|
||||
|
||||
1. Grant delegation via the delegation API (preferred for token issuance; see
|
||||
ARCHITECTURE.md §21):
|
||||
|
||||
```sh
|
||||
mciasctl accounts token delegates grant \
|
||||
-id <worker-bot-uuid> -grantee <bob-uuid>
|
||||
```
|
||||
|
||||
Or, equivalently, via a policy rule:
|
||||
|
||||
```json
|
||||
{
|
||||
"effect": "allow",
|
||||
"subject_uuid": "<bob-uuid>",
|
||||
"actions": ["tokens:issue", "tokens:renew"],
|
||||
"resource_type": "token",
|
||||
"service_names": ["worker-bot"]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
mciasctl policy create -description "Bob: issue worker-bot token" \
|
||||
-json rule.json -priority 50
|
||||
```
|
||||
|
||||
2. Bob uses the `/service-accounts` UI page or `mciasctl` to rotate the token
|
||||
and download it via the one-time nonce endpoint.
|
||||
|
||||
---
|
||||
|
||||
### Example F — Deny a specific account from all access
|
||||
|
||||
**Goal:** Temporarily block `mallory` (UUID known) from all operations without
|
||||
deleting the account.
|
||||
|
||||
```json
|
||||
{
|
||||
"effect": "deny",
|
||||
"subject_uuid": "<mallory-uuid>"
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
mciasctl policy create -description "Block mallory (incident response)" \
|
||||
-json rule.json -priority 1
|
||||
```
|
||||
|
||||
Priority 1 ensures this deny fires before any allow rule. Because deny-wins
|
||||
applies globally (not just within a priority band), this blocks mallory even
|
||||
though the admin wildcard (priority 0, allow) would otherwise match. Note: the
|
||||
admin wildcard is an `allow` rule; a `deny` at any priority overrides it for
|
||||
the matched principal.
|
||||
|
||||
To lift the block, delete or disable the rule:
|
||||
|
||||
```sh
|
||||
mciasctl policy update -id <rule-id> -enabled=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Security Recommendations
|
||||
|
||||
1. **Prefer explicit deny rules for sensitive resources.** Use `required_tags`
|
||||
or `service_names` to scope allow rules narrowly, and add a corresponding
|
||||
deny rule at a lower priority number for the resources that must never be
|
||||
accessible.
|
||||
|
||||
2. **Use time-scoped rules for temporary access.** Set `expires_at` instead of
|
||||
creating a rule and relying on manual deletion. The engine enforces expiry
|
||||
automatically at cache-load time.
|
||||
|
||||
3. **Avoid wildcard allow rules without resource scoping.** A rule with only
|
||||
`roles` and `actions` but no `resource_type`, `service_names`, or
|
||||
`required_tags` matches every resource of every type. Scope rules as
|
||||
narrowly as the use case allows.
|
||||
|
||||
4. **Audit deny events.** Every explicit deny produces a `policy_deny` audit
|
||||
event. Review the audit log (`GET /v1/audit` or the `/audit` UI page)
|
||||
regularly to detect unexpected access patterns.
|
||||
|
||||
5. **Do not rely on priority alone for security boundaries.** Priority controls
|
||||
evaluation order, not security strength. A deny rule at priority 100 still
|
||||
overrides an allow at priority 50 if both match. Use deny rules explicitly
|
||||
rather than assuming a lower-priority allow will be shadowed.
|
||||
|
||||
6. **Keep the built-in defaults intact.** The compiled-in rules reproduce the
|
||||
baseline admin/self-service behavior. Custom rules extend this baseline;
|
||||
they cannot disable the defaults. Do not attempt to work around them by
|
||||
creating conflicting operator rules — the deny-wins semantics mean an
|
||||
operator deny at priority 1 will block even the admin wildcard for the
|
||||
matched principal.
|
||||
|
||||
7. **Reload after bulk changes.** After importing many rules via the REST API,
|
||||
send `SIGHUP` to `mciassrv` to force an immediate cache reload rather than
|
||||
waiting for the next individual rule event.
|
||||
|
||||
---
|
||||
|
||||
## 8. Audit Events
|
||||
|
||||
| Event | Trigger |
|
||||
|---|---|
|
||||
| `policy_deny` | Engine denied a request; payload: `{action, resource_type, service_name, required_tags, matched_rule_id}` — never contains credential material |
|
||||
| `policy_rule_created` | New operator rule created |
|
||||
| `policy_rule_updated` | Rule priority, enabled flag, or description changed |
|
||||
| `policy_rule_deleted` | Rule deleted |
|
||||
| `tag_added` | Tag added to an account |
|
||||
| `tag_removed` | Tag removed from an account |
|
||||
|
||||
All events are written to the `audit_events` table and are visible via
|
||||
`GET /v1/audit` (admin JWT required) or the `/audit` web UI page.
|
||||
134
PROGRESS.md
134
PROGRESS.md
@@ -2,7 +2,139 @@
|
||||
|
||||
Source of truth for current development state.
|
||||
---
|
||||
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean (pre-existing warnings only).
|
||||
Phases 0–14 complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
||||
|
||||
### 2026-03-16 — TOTP enrollment via web UI
|
||||
|
||||
**Task:** Add TOTP enrollment and management to the web UI profile page.
|
||||
|
||||
**Changes:**
|
||||
- **Dependency:** `github.com/skip2/go-qrcode` for server-side QR code generation
|
||||
- **Profile page:** TOTP section showing enabled status or enrollment form
|
||||
- **Enrollment flow:** Password re-auth → generate secret → show QR code + manual entry → confirm with 6-digit code
|
||||
- **QR code:** Generated server-side as `data:image/png;base64,...` URI (CSP-compliant)
|
||||
- **Account detail:** Admin "Remove TOTP" button with HTMX delete + confirm
|
||||
- **Enrollment nonces:** `pendingTOTPEnrolls sync.Map` with 5-minute TTL, single-use
|
||||
- **Template fragments:** `totp_section.html`, `totp_enroll_qr.html`
|
||||
- **Handler:** `internal/ui/handlers_totp.go` with `handleTOTPEnrollStart`, `handleTOTPConfirm`, `handleAdminTOTPRemove`
|
||||
- **Security:** Password re-auth (SEC-01), lockout check, CSRF, single-use nonces, TOTP counter replay prevention (CRIT-01)
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-16 — Phase 14: FIDO2/WebAuthn and Passkey Authentication
|
||||
|
||||
**Task:** Add FIDO2/WebAuthn support for passwordless passkey login and security key 2FA.
|
||||
|
||||
**Changes:**
|
||||
- **Dependency:** `github.com/go-webauthn/webauthn v0.16.1`
|
||||
- **Config:** `WebAuthnConfig` struct with RPID, RPOrigin, DisplayName; validation; `WebAuthnEnabled()` method
|
||||
- **Model:** `WebAuthnCredential` struct with encrypted credential fields; 4 audit events; 2 policy actions
|
||||
- **Migration 000009:** `webauthn_credentials` table with encrypted credential ID/pubkey, sign counter, discoverable flag
|
||||
- **DB layer:** Full CRUD in `internal/db/webauthn.go` (create, get, delete with ownership, admin delete, delete all, sign count, last used, has, count)
|
||||
- **Adapter:** `internal/webauthn/` package — library initialization, `AccountUser` interface, AES-256-GCM encrypt/decrypt round-trip
|
||||
- **Policy:** Default rule -8 for self-service enrollment
|
||||
- **REST API:** 6 endpoints (register begin/finish, login begin/finish, list credentials, delete credential) with `sync.Map` ceremony store
|
||||
- **Web UI:** Profile page enrollment+management, login page passkey button, admin account detail passkeys section, CSP-compliant `webauthn.js`
|
||||
- **gRPC:** `ListWebAuthnCredentials` and `RemoveWebAuthnCredential` RPCs with handler
|
||||
- **mciasdb:** `webauthn list/delete/reset` subcommands and `account reset-webauthn` alias
|
||||
- **OpenAPI:** All 6 endpoints documented; `WebAuthnCredentialInfo` schema; `webauthn_enabled`/`webauthn_count` on Account
|
||||
- **Tests:** DB CRUD tests, adapter encrypt/decrypt round-trip, interface compliance, wrong-key rejection
|
||||
- **Docs:** ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14, PROGRESS.md
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-16 — Documentation sync (ARCHITECTURE.md, PROJECT_PLAN.md)
|
||||
|
||||
**Task:** Full documentation audit to sync ARCHITECTURE.md and PROJECT_PLAN.md with v1.0.0 implementation.
|
||||
|
||||
**ARCHITECTURE.md changes:**
|
||||
- §8 Postgres Credential Endpoints: added missing `GET /v1/pgcreds`
|
||||
- §12 Directory/Package Structure: added `internal/audit/`, `internal/vault/`, `web/embed.go`; added `clients/`, `test/`, `dist/`, `man/` top-level dirs; removed stale "(Phase N)" labels
|
||||
- §17 Proto Package Layout: added `policy.proto`
|
||||
- §17 Service Definitions: added `PolicyService` row
|
||||
- §18 Makefile Targets: added `docker-clean`; corrected `docker` and `clean` descriptions
|
||||
|
||||
**PROJECT_PLAN.md changes:**
|
||||
- All phases 0–9 marked `[COMPLETE]`
|
||||
- Added status summary at top (v1.0.0, 2026-03-15)
|
||||
- Phase 4.1: added `mciasctl pgcreds list` subcommand (implemented, was missing from plan)
|
||||
- Phase 7.1: added `policy.proto` to proto file list
|
||||
- Phase 8.5: added `docker-clean` target; corrected `docker` and `clean` target descriptions
|
||||
- Added Phase 10: Web UI (HTMX)
|
||||
- Added Phase 11: Authorization Policy Engine
|
||||
- Added Phase 12: Vault Seal/Unseal Lifecycle
|
||||
- Added Phase 13: Token Delegation and pgcred Access Grants
|
||||
- Updated implementation order to include phases 10–13
|
||||
|
||||
**No code changes.** Documentation only.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-15 — Makefile: docker image cleanup
|
||||
|
||||
**Task:** Ensure `make clean` removes Docker build images; add dedicated `docker-clean` target.
|
||||
|
||||
**Changes:**
|
||||
- `clean` target now runs `docker rmi mcias:$(VERSION) mcias:latest` (errors suppressed so clean works without Docker).
|
||||
- New `docker-clean` target removes the versioned and `latest` tags and prunes dangling images with the mcias label.
|
||||
- Header comment and `help` target updated to document `docker-clean`.
|
||||
|
||||
**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-15 — Fix Swagger server URLs
|
||||
|
||||
**Task:** Update Swagger `servers` section to use correct auth server URLs.
|
||||
|
||||
**Changes:**
|
||||
- `openapi.yaml` and `web/static/openapi.yaml`: replaced `https://auth.example.com:8443` with `https://mcias.metacircular.net:8443` (Production) and `https://localhost:8443` (Local test server).
|
||||
|
||||
**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-15 — Fix /docs Swagger UI (bundle assets locally)
|
||||
|
||||
**Problem:** `/docs` was broken because `docs.html` loaded `swagger-ui-bundle.js` and `swagger-ui.css` from `unpkg.com` CDN, which is blocked by the server's `Content-Security-Policy: default-src 'self'` header.
|
||||
|
||||
**Solution:**
|
||||
- Downloaded `swagger-ui-dist@5.32.0` via npm and copied `swagger-ui-bundle.js` and `swagger-ui.css` into `web/static/` (embedded at build time).
|
||||
- Updated `docs.html` to reference `/static/swagger-ui-bundle.js` and `/static/swagger-ui.css`.
|
||||
- Added `GET /static/swagger-ui-bundle.js` and `GET /static/swagger-ui.css` handlers in `server.go` serving the embedded bytes with correct `Content-Type` headers.
|
||||
- No CSP changes required; strict `default-src 'self'` is preserved.
|
||||
|
||||
**Verification:** `go build ./...`, `go test ./...`, `golangci-lint run ./...` all clean.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-15 — Checkpoint: lint fixes
|
||||
|
||||
**Task:** Checkpoint — lint clean, tests pass, commit.
|
||||
|
||||
**Lint fixes (13 issues resolved):**
|
||||
- `errorlint`: `internal/vault/vault_test.go` — replaced `err != ErrSealed` with `errors.Is(err, ErrSealed)`.
|
||||
- `gofmt`: `internal/config/config.go`, `internal/config/config_test.go`, `internal/middleware/middleware_test.go` — reformatted with `goimports`.
|
||||
- `govet/fieldalignment`: `internal/vault/vault.go`, `internal/ui/csrf.go`, `internal/audit/detail_test.go`, `internal/middleware/middleware_test.go` — reordered struct fields for optimal alignment.
|
||||
- `unused`: `internal/ui/csrf.go` — removed unused `newCSRFManager` function (superseded by `newCSRFManagerFromVault`).
|
||||
- `revive/early-return`: `cmd/mciassrv/main.go` — inverted condition to eliminate else-after-return.
|
||||
|
||||
**Verification:** `golangci-lint run ./...` → 0 issues; `go test ./...` → all packages pass.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-15 — Documentation: ARCHITECTURE.md update + POLICY.md
|
||||
|
||||
**Task:** Ensure ARCHITECTURE.md is accurate; add POLICY.md describing the policy engine.
|
||||
|
||||
**ARCHITECTURE.md fix:**
|
||||
- Corrected `Rule.ID` comment: built-in default rules use negative IDs (-1 … -7), not 0 (§20 Core Types code block).
|
||||
|
||||
**New file: POLICY.md**
|
||||
- Operator reference guide for the ABAC policy engine.
|
||||
- Covers: evaluation model (deny-wins, default-deny, stable priority sort), rule matching semantics, priority conventions, all built-in default rules (IDs -1 … -7) with conditions, full action and resource-type catalogue, rule schema (DB columns + RuleBody JSON), rule management via `mciasctl` / REST API / Web UI, account tag conventions, cache reload, six worked examples (named service delegation, machine-tag gating, blanket role, time-scoped access, per-account subject rule, incident-response deny), security recommendations, and audit events.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-15 — Service account token delegation and download
|
||||
|
||||
|
||||
262
PROJECT_PLAN.md
262
PROJECT_PLAN.md
@@ -5,11 +5,23 @@ See ARCHITECTURE.md for design rationale.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Repository Bootstrap
|
||||
## Status
|
||||
|
||||
**v1.0.0 tagged (2026-03-15). All phases complete.**
|
||||
|
||||
All packages pass `go test ./...`; `golangci-lint run ./...` clean.
|
||||
See PROGRESS.md for the detailed development log.
|
||||
|
||||
Phases 0–9 match the original plan. Phases 10–13 document significant
|
||||
features implemented beyond the original plan scope.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Repository Bootstrap **[COMPLETE]**
|
||||
|
||||
### Step 0.1: Go module and dependency setup
|
||||
**Acceptance criteria:**
|
||||
- `go.mod` exists with module path `git.wntrmute.dev/kyle/mcias`
|
||||
- `go.mod` exists with module path `git.wntrmute.dev/mc/mcias`
|
||||
- Required dependencies declared: `modernc.org/sqlite` (CGo-free SQLite),
|
||||
`golang.org/x/crypto` (Argon2, Ed25519 helpers), `github.com/golang-jwt/jwt/v5`,
|
||||
`github.com/pelletier/go-toml/v2`, `github.com/google/uuid`,
|
||||
@@ -23,7 +35,7 @@ See ARCHITECTURE.md for design rationale.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Foundational Packages
|
||||
## Phase 1 — Foundational Packages **[COMPLETE]**
|
||||
|
||||
### Step 1.1: `internal/model` — shared data types
|
||||
**Acceptance criteria:**
|
||||
@@ -69,7 +81,7 @@ See ARCHITECTURE.md for design rationale.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Authentication Core
|
||||
## Phase 2 — Authentication Core **[COMPLETE]**
|
||||
|
||||
### Step 2.1: `internal/token` — JWT issuance and validation
|
||||
**Acceptance criteria:**
|
||||
@@ -107,7 +119,7 @@ See ARCHITECTURE.md for design rationale.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — HTTP Server
|
||||
## Phase 3 — HTTP Server **[COMPLETE]**
|
||||
|
||||
### Step 3.1: `internal/middleware` — HTTP middleware
|
||||
**Acceptance criteria:**
|
||||
@@ -143,6 +155,7 @@ See ARCHITECTURE.md for design rationale.
|
||||
- `POST /v1/auth/totp/confirm` — confirms TOTP enrollment
|
||||
- `DELETE /v1/auth/totp` — admin; removes TOTP from account
|
||||
- `GET|PUT /v1/accounts/{id}/pgcreds` — get/set Postgres credentials
|
||||
- `GET /v1/pgcreds` — list all accessible credentials (owned + granted)
|
||||
- Credential fields (password hash, TOTP secret, Postgres password) are
|
||||
**never** included in any API response
|
||||
- Tests: each endpoint happy path; auth middleware applied correctly; invalid
|
||||
@@ -160,7 +173,7 @@ See ARCHITECTURE.md for design rationale.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Admin CLI
|
||||
## Phase 4 — Admin CLI **[COMPLETE]**
|
||||
|
||||
### Step 4.1: `cmd/mciasctl` — admin CLI
|
||||
**Acceptance criteria:**
|
||||
@@ -177,6 +190,7 @@ See ARCHITECTURE.md for design rationale.
|
||||
- `mciasctl role revoke -id UUID -role ROLE`
|
||||
- `mciasctl token issue -id UUID` (system accounts)
|
||||
- `mciasctl token revoke -jti JTI`
|
||||
- `mciasctl pgcreds list`
|
||||
- `mciasctl pgcreds set -id UUID -host H -port P -db D -user U`
|
||||
- `mciasctl pgcreds get -id UUID`
|
||||
- `mciasctl auth login`
|
||||
@@ -191,7 +205,7 @@ See ARCHITECTURE.md for design rationale.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — End-to-End Tests and Hardening
|
||||
## Phase 5 — End-to-End Tests and Hardening **[COMPLETE]**
|
||||
|
||||
### Step 5.1: End-to-end test suite
|
||||
**Acceptance criteria:**
|
||||
@@ -228,7 +242,7 @@ See ARCHITECTURE.md for design rationale.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — mciasdb: Database Maintenance Tool
|
||||
## Phase 6 — mciasdb: Database Maintenance Tool **[COMPLETE]**
|
||||
|
||||
See ARCHITECTURE.md §16 for full design rationale, trust model, and command
|
||||
surface.
|
||||
@@ -314,9 +328,7 @@ surface.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — gRPC Interface
|
||||
## Phase 7 — gRPC Interface **[COMPLETE]**
|
||||
|
||||
See ARCHITECTURE.md §17 for full design rationale, proto definitions, and
|
||||
transport security requirements.
|
||||
@@ -324,7 +336,8 @@ transport security requirements.
|
||||
### Step 7.1: Protobuf definitions and generated code
|
||||
**Acceptance criteria:**
|
||||
- `proto/mcias/v1/` directory contains `.proto` files for all service groups:
|
||||
`auth.proto`, `token.proto`, `account.proto`, `admin.proto`
|
||||
`auth.proto`, `token.proto`, `account.proto`, `policy.proto`, `admin.proto`,
|
||||
`common.proto`
|
||||
- All RPC methods mirror the REST API surface (see ARCHITECTURE.md §8 and §17)
|
||||
- `proto/generate.go` contains a `//go:generate protoc ...` directive that
|
||||
produces Go stubs under `gen/mcias/v1/` using `protoc-gen-go` and
|
||||
@@ -357,10 +370,11 @@ transport security requirements.
|
||||
- gRPC server uses the same TLS certificate and key as the REST server (loaded
|
||||
from config); minimum TLS 1.2 enforced via `tls.Config`
|
||||
- Unary server interceptor chain:
|
||||
1. Request logger (method name, peer IP, status, duration)
|
||||
2. Auth interceptor (extracts Bearer token, validates, injects claims into
|
||||
1. Sealed interceptor (blocks all RPCs when vault sealed, except Health)
|
||||
2. Request logger (method name, peer IP, status, duration)
|
||||
3. Auth interceptor (extracts Bearer token, validates, injects claims into
|
||||
`context.Context`)
|
||||
3. Rate-limit interceptor (per-IP token bucket, same parameters as REST)
|
||||
4. Rate-limit interceptor (per-IP token bucket, same parameters as REST)
|
||||
- No credential material logged by any interceptor
|
||||
- Tests: interceptor chain applied correctly; rate-limit triggers after burst
|
||||
|
||||
@@ -396,7 +410,7 @@ transport security requirements.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Operational Artifacts
|
||||
## Phase 8 — Operational Artifacts **[COMPLETE]**
|
||||
|
||||
See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
|
||||
|
||||
@@ -461,7 +475,10 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
|
||||
- `generate` — `go generate ./...` (proto stubs from Phase 7)
|
||||
- `man` — build compressed man pages
|
||||
- `install` — run `dist/install.sh`
|
||||
- `clean` — remove `bin/` and generated artifacts
|
||||
- `docker` — `docker build -t mcias:$(VERSION) -t mcias:latest .`
|
||||
- `docker-clean` — remove local `mcias:$(VERSION)` and `mcias:latest` images;
|
||||
prune dangling images with the mcias label
|
||||
- `clean` — remove `bin/`, compressed man pages, and local Docker images
|
||||
- `dist` — build release tarballs for linux/amd64 and linux/arm64 (using
|
||||
`GOOS`/`GOARCH` cross-compilation)
|
||||
- `make build` works from a clean checkout after `go mod download`
|
||||
@@ -483,13 +500,10 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
|
||||
- `dist/mcias.conf.docker.example` — config template suitable for container
|
||||
deployment: `listen_addr = "0.0.0.0:8443"`, `grpc_addr = "0.0.0.0:9443"`,
|
||||
`db_path = "/data/mcias.db"`, TLS cert/key paths under `/etc/mcias/`
|
||||
- `Makefile` gains a `docker` target: `docker build -t mcias:$(VERSION) .`
|
||||
where `VERSION` defaults to the output of `git describe --tags --always`
|
||||
- Tests:
|
||||
- `docker build .` completes without error (run in CI if Docker available;
|
||||
skip gracefully if not)
|
||||
- `docker run --rm mcias:latest mciassrv --help` exits 0
|
||||
- Image size documented in PROGRESS.md (target: under 50 MB)
|
||||
|
||||
### Step 8.7: Documentation
|
||||
**Acceptance criteria:**
|
||||
@@ -501,7 +515,7 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Client Libraries
|
||||
## Phase 9 — Client Libraries **[COMPLETE]**
|
||||
|
||||
See ARCHITECTURE.md §19 for full design rationale, API surface, and per-language
|
||||
implementation notes.
|
||||
@@ -529,7 +543,7 @@ implementation notes.
|
||||
|
||||
### Step 9.2: Go client library
|
||||
**Acceptance criteria:**
|
||||
- `clients/go/` — Go module `git.wntrmute.dev/kyle/mcias/clients/go`
|
||||
- `clients/go/` — Go module `git.wntrmute.dev/mc/mcias/clients/go`
|
||||
- Package `mciasgoclient` exposes the canonical API surface from Step 9.1
|
||||
- Uses `net/http` with `crypto/tls`; custom CA cert supported via `x509.CertPool`
|
||||
- Token stored in-memory; `Client.Token()` accessor returns current token
|
||||
@@ -606,6 +620,203 @@ implementation notes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Web UI (HTMX) **[COMPLETE]**
|
||||
|
||||
Not in the original plan. Implemented alongside and after Phase 3.
|
||||
|
||||
See ARCHITECTURE.md §8 (Web Management UI) for design details.
|
||||
|
||||
### Step 10.1: `internal/ui` — HTMX web interface
|
||||
**Acceptance criteria:**
|
||||
- Go `html/template` pages embedded at compile time via `web/embed.go`
|
||||
- CSRF protection: HMAC-signed double-submit cookie (`mcias_csrf`)
|
||||
- Session: JWT stored as `HttpOnly; Secure; SameSite=Strict` cookie
|
||||
- Security headers: `Content-Security-Policy: default-src 'self'`,
|
||||
`X-Frame-Options: DENY`, `Referrer-Policy: strict-origin`
|
||||
- Pages: login, dashboard, account list/detail, role editor, tag editor,
|
||||
pgcreds, audit log viewer, policy rules, user profile, service-accounts
|
||||
- HTMX partial-page updates for mutations (role updates, tag edits, policy
|
||||
toggles, access grants)
|
||||
- Empty-state handling on all list pages (zero records case tested)
|
||||
|
||||
### Step 10.2: Swagger UI at `/docs`
|
||||
**Acceptance criteria:**
|
||||
- `GET /docs` serves Swagger UI for `openapi.yaml`
|
||||
- swagger-ui-bundle.js and swagger-ui.css bundled locally in `web/static/`
|
||||
(CDN blocked by CSP `default-src 'self'`)
|
||||
- `GET /docs/openapi.yaml` serves the OpenAPI spec
|
||||
- `openapi.yaml` kept in sync with REST API surface
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — Authorization Policy Engine **[COMPLETE]**
|
||||
|
||||
Not in the original plan (CLI subcommands for policy were planned in Phase 4,
|
||||
but the engine itself was not a discrete plan phase).
|
||||
|
||||
See ARCHITECTURE.md §20 for full design, evaluation algorithm, and built-in
|
||||
default rules.
|
||||
|
||||
### Step 11.1: `internal/policy` — in-process ABAC engine
|
||||
**Acceptance criteria:**
|
||||
- Pure evaluation: `Evaluate(input PolicyInput, rules []Rule) (Effect, *Rule)`
|
||||
- Deny-wins: any explicit deny overrides all allows
|
||||
- Default-deny: no matching rule → deny
|
||||
- Built-in default rules (IDs -1 … -7) compiled in; reproduce previous
|
||||
binary admin/non-admin behavior exactly; cannot be disabled via API
|
||||
- Match fields: roles, account types, subject UUID, actions, resource type,
|
||||
owner-matches-subject, service names, required tags (all ANDed; zero value
|
||||
= wildcard)
|
||||
- Temporal constraints on DB-backed rules: `not_before`, `expires_at`
|
||||
- `Engine` wrapper: caches rule set in memory; reloads on policy mutations
|
||||
- Tests: all built-in rules; deny-wins over allow; default-deny fallback;
|
||||
temporal filtering; concurrent access
|
||||
|
||||
### Step 11.2: Middleware and REST integration
|
||||
**Acceptance criteria:**
|
||||
- `RequirePolicy(engine, action, resourceType)` middleware replaces
|
||||
`RequireRole("admin")` where policy-gated
|
||||
- Every explicit deny produces a `policy_deny` audit event
|
||||
- REST endpoints: `GET|POST /v1/policy/rules`, `GET|PATCH|DELETE /v1/policy/rules/{id}`
|
||||
- DB schema: `policy_rules` and `account_tags` tables (migrations 000004,
|
||||
000006)
|
||||
- `PATCH /v1/policy/rules/{id}` supports updating `priority`, `enabled`,
|
||||
`not_before`, `expires_at`
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Vault Seal/Unseal Lifecycle **[COMPLETE]**
|
||||
|
||||
Not in the original plan.
|
||||
|
||||
See ARCHITECTURE.md §8 (Vault Endpoints) for the API surface.
|
||||
|
||||
### Step 12.1: `internal/vault` — master key lifecycle
|
||||
**Acceptance criteria:**
|
||||
- Thread-safe `Vault` struct with `sync.RWMutex`-protected state
|
||||
- Methods: `IsSealed()`, `Unseal(passphrase)`, `Seal()`, `MasterKey()`,
|
||||
`PrivKey()`, `PubKey()`
|
||||
- `Seal()` zeroes all key material before nilling (memguard-style cleanup)
|
||||
- `DeriveFromPassphrase()` and `DecryptSigningKey()` extracted to `derive.go`
|
||||
for reuse by unseal handlers
|
||||
- Tests: state transitions; key zeroing verified; concurrent read/write safety
|
||||
|
||||
### Step 12.2: REST and UI integration
|
||||
**Acceptance criteria:**
|
||||
- `POST /v1/vault/unseal` — rate-limited (3/s burst 5); derives key, unseals
|
||||
- `GET /v1/vault/status` — always accessible; returns `{"sealed": bool}`
|
||||
- `POST /v1/vault/seal` — admin only; zeroes key material
|
||||
- `GET /v1/health` returns `{"status":"sealed"}` when sealed
|
||||
- All other `/v1/*` endpoints return 503 `vault_sealed` when sealed
|
||||
- UI redirects all paths to `/unseal` when sealed (except `/static/`)
|
||||
- gRPC: `sealedInterceptor` first in chain; blocks all RPCs except Health
|
||||
- Startup: server may start in sealed state if passphrase env var is absent
|
||||
- Audit events: `vault_sealed`, `vault_unsealed`
|
||||
|
||||
---
|
||||
|
||||
## Phase 13 — Token Delegation and pgcred Access Grants **[COMPLETE]**
|
||||
|
||||
Not in the original plan.
|
||||
|
||||
See ARCHITECTURE.md §21 (Token Issuance Delegation) for design details.
|
||||
|
||||
### Step 13.1: Service account token delegation
|
||||
**Acceptance criteria:**
|
||||
- DB migration 000008: `service_account_delegates` table
|
||||
- `POST /accounts/{id}/token/delegates` — admin grants delegation
|
||||
- `DELETE /accounts/{id}/token/delegates/{grantee}` — admin revokes delegation
|
||||
- `POST /accounts/{id}/token` — accepts admin or delegate (not admin-only)
|
||||
- One-time token download: nonce stored in `sync.Map` with 5-minute TTL;
|
||||
`GET /token/download/{nonce}` serves token as attachment, deletes nonce
|
||||
- `/service-accounts` page for non-admin delegates
|
||||
- Audit events: `token_delegate_granted`, `token_delegate_revoked`
|
||||
|
||||
### Step 13.2: pgcred fine-grained access grants
|
||||
**Acceptance criteria:**
|
||||
- DB migration 000005: `pgcred_access_grants` table
|
||||
- `POST /accounts/{id}/pgcreds/access` — owner grants read access to grantee
|
||||
- `DELETE /accounts/{id}/pgcreds/access/{grantee}` — owner revokes access
|
||||
- `GET /v1/pgcreds` — lists all credentials accessible to caller (owned +
|
||||
granted); includes credential ID for reference
|
||||
- Grantees may view connection metadata; password is never decrypted for them
|
||||
- Audit events: `pgcred_access_granted`, `pgcred_access_revoked`
|
||||
|
||||
---
|
||||
|
||||
## Phase 14 — FIDO2/WebAuthn and Passkey Authentication
|
||||
|
||||
**Goal:** Add FIDO2/WebAuthn support for passwordless passkey login and hardware
|
||||
security key 2FA. Discoverable credentials enable passwordless login;
|
||||
non-discoverable credentials serve as 2FA. Either WebAuthn or TOTP satisfies
|
||||
the 2FA requirement.
|
||||
|
||||
### Step 14.1: Dependency, config, and model types
|
||||
**Acceptance criteria:**
|
||||
- `github.com/go-webauthn/webauthn` dependency added
|
||||
- `WebAuthnConfig` struct in config with RPID, RPOrigin, DisplayName
|
||||
- Validation: if any field set, RPID+RPOrigin required; RPOrigin must be HTTPS
|
||||
- `WebAuthnCredential` model type with encrypted-at-rest fields
|
||||
- Audit events: `webauthn_enrolled`, `webauthn_removed`, `webauthn_login_ok`, `webauthn_login_fail`
|
||||
- Policy actions: `ActionEnrollWebAuthn`, `ActionRemoveWebAuthn`
|
||||
|
||||
### Step 14.2: Database migration and CRUD
|
||||
**Acceptance criteria:**
|
||||
- Migration 000009: `webauthn_credentials` table with encrypted credential fields
|
||||
- Full CRUD: Create, Get (by ID, by account), Delete (ownership-checked and admin),
|
||||
DeleteAll, UpdateSignCount, UpdateLastUsed, Has, Count
|
||||
- DB tests for all operations including ownership checks and cascade behavior
|
||||
|
||||
### Step 14.3: WebAuthn adapter package
|
||||
**Acceptance criteria:**
|
||||
- `internal/webauthn/` package with adapter, user, and converter
|
||||
- `NewWebAuthn(cfg)` factory wrapping library initialization
|
||||
- `AccountUser` implementing `webauthn.User` interface
|
||||
- `EncryptCredential`/`DecryptCredential`/`DecryptCredentials` round-trip encryption
|
||||
- Tests for encrypt/decrypt, interface compliance, wrong-key rejection
|
||||
|
||||
### Step 14.4: REST endpoints
|
||||
**Acceptance criteria:**
|
||||
- `POST /v1/auth/webauthn/register/begin` — password re-auth, returns creation options
|
||||
- `POST /v1/auth/webauthn/register/finish` — completes registration, encrypts credential
|
||||
- `POST /v1/auth/webauthn/login/begin` — discoverable and username-scoped flows
|
||||
- `POST /v1/auth/webauthn/login/finish` — validates assertion, issues JWT
|
||||
- `GET /v1/accounts/{id}/webauthn` — admin, returns metadata only
|
||||
- `DELETE /v1/accounts/{id}/webauthn/{credentialId}` — admin remove
|
||||
- Challenge store: `sync.Map` with 120s TTL, background cleanup
|
||||
|
||||
### Step 14.5: Web UI
|
||||
**Acceptance criteria:**
|
||||
- Profile page: passkey enrollment form, credential list with delete
|
||||
- Login page: "Sign in with passkey" button with discoverable flow
|
||||
- Account detail page: passkey section with admin remove
|
||||
- CSP-compliant `webauthn.js` (external script, base64url helpers)
|
||||
- Empty state handling for zero credentials
|
||||
|
||||
### Step 14.6: gRPC handlers
|
||||
**Acceptance criteria:**
|
||||
- Proto messages and RPCs: `ListWebAuthnCredentials`, `RemoveWebAuthnCredential`
|
||||
- gRPC handler implementation delegating to shared packages
|
||||
- Regenerated protobuf stubs
|
||||
|
||||
### Step 14.7: mciasdb offline management
|
||||
**Acceptance criteria:**
|
||||
- `mciasdb webauthn list --id UUID`
|
||||
- `mciasdb webauthn delete --id UUID --credential-id N`
|
||||
- `mciasdb webauthn reset --id UUID` (deletes all)
|
||||
- `mciasdb account reset-webauthn --id UUID` alias
|
||||
- All operations write audit events
|
||||
|
||||
### Step 14.8: OpenAPI and documentation
|
||||
**Acceptance criteria:**
|
||||
- All 6 REST endpoints documented in openapi.yaml
|
||||
- `WebAuthnCredentialInfo` schema, `webauthn_enabled`/`webauthn_count` on Account
|
||||
- ARCHITECTURE.md §22 with design details
|
||||
- PROJECT_PLAN.md Phase 14
|
||||
- PROGRESS.md updated
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
@@ -618,6 +829,13 @@ Phase 0 → Phase 1 (1.1, 1.2, 1.3, 1.4 in parallel or sequence)
|
||||
→ Phase 7 (7.1 → 7.2 → 7.3 → 7.4 → 7.5 → 7.6)
|
||||
→ Phase 8 (8.1 → 8.2 → 8.3 → 8.4 → 8.5 → 8.6)
|
||||
→ Phase 9 (9.1 → 9.2 → 9.3 → 9.4 → 9.5 → 9.6)
|
||||
→ Phase 10 (interleaved with Phase 3 and later phases)
|
||||
→ Phase 11 (interleaved with Phase 3–4)
|
||||
→ Phase 12 (post Phase 3)
|
||||
→ Phase 13 (post Phase 3 and 11)
|
||||
→ Phase 14 (post v1.0.0)
|
||||
```
|
||||
|
||||
Each step must have passing tests before the next step begins.
|
||||
Phases 0–13 complete as of v1.0.0 (2026-03-15).
|
||||
Phase 14 complete as of 2026-03-16.
|
||||
|
||||
@@ -17,7 +17,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the technical design and
|
||||
**Prerequisites:** Go 1.26+, a C compiler (required by modernc.org/sqlite).
|
||||
|
||||
```sh
|
||||
git clone https://git.wntrmute.dev/kyle/mcias
|
||||
git clone https://git.wntrmute.dev/mc/mcias
|
||||
cd mcias
|
||||
make build # produces bin/mciassrv, other binaries
|
||||
sudo make install
|
||||
|
||||
90
RUNBOOK.md
90
RUNBOOK.md
@@ -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
|
||||
|
||||
> This operation is not yet automated. Until a rotation command is
|
||||
@@ -384,6 +461,19 @@ See `dist/mcias.conf.docker.example` for the full annotated Docker config.
|
||||
|
||||
---
|
||||
|
||||
## MCP Deployment
|
||||
|
||||
MCIAS is **not** managed by MCP and does not run on rift. Because MCIAS is the
|
||||
authentication root for the entire platform — including MCP itself — running it
|
||||
under MCP would create a circular dependency. Instead, MCIAS runs as a systemd
|
||||
service on a separate VPS (`svc.metacircular.net`).
|
||||
|
||||
All deployment, upgrades, and operational tasks use systemd directly on the VPS.
|
||||
See the [Installation](#installation), [Routine Operations](#routine-operations),
|
||||
and [Upgrading](#upgrading) sections above for the relevant procedures.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server fails to start: "open database"
|
||||
|
||||
14
buf.yaml
Normal file
14
buf.yaml
Normal 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
|
||||
@@ -29,7 +29,7 @@ set_pg_creds(account_id, host, port, database, username, password) → void
|
||||
| `MciasConflictError` | 409 | Conflict (e.g. duplicate username) |
|
||||
| `MciasServerError` | 5xx | Unexpected server error |
|
||||
`testdata/` contains canonical JSON response fixtures shared across language tests.
|
||||
- `go/` — Go module `git.wntrmute.dev/kyle/mcias/clients/go`
|
||||
- `go/` — Go module `git.wntrmute.dev/mc/mcias/clients/go`
|
||||
- `rust/` — Rust crate `mcias-client`
|
||||
- `lisp/` — ASDF system `mcias-client`
|
||||
- `python/` — Python package `mcias_client`
|
||||
|
||||
@@ -9,13 +9,13 @@ Go client library for the [MCIAS](../../README.md) identity and access managemen
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
go get git.wntrmute.dev/kyle/mcias/clients/go
|
||||
go get git.wntrmute.dev/mc/mcias/clients/go
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
import "git.wntrmute.dev/kyle/mcias/clients/go/mcias"
|
||||
import "git.wntrmute.dev/mc/mcias/clients/go/mcias"
|
||||
|
||||
// Connect to the MCIAS server.
|
||||
client, err := mcias.New("https://auth.example.com", mcias.Options{})
|
||||
|
||||
@@ -185,16 +185,28 @@ type Options struct {
|
||||
CACertPath string
|
||||
// Token is an optional pre-existing bearer token.
|
||||
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.
|
||||
// Security: the bearer token is guarded by a sync.RWMutex; it is never
|
||||
// written to logs or included in error messages in this library.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
mu sync.RWMutex
|
||||
token string
|
||||
baseURL string
|
||||
http *http.Client
|
||||
serviceName 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}
|
||||
c := &Client{
|
||||
baseURL: serverURL,
|
||||
http: &http.Client{Transport: transport},
|
||||
token: opts.Token,
|
||||
baseURL: serverURL,
|
||||
http: &http.Client{Transport: transport},
|
||||
token: opts.Token,
|
||||
serviceName: opts.ServiceName,
|
||||
tags: opts.Tags,
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -343,16 +357,28 @@ func (c *Client) GetPublicKey() (*PublicKey, error) {
|
||||
// Login authenticates with username and password. On success the token is
|
||||
// stored in the Client and returned along with the expiry timestamp.
|
||||
// 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) {
|
||||
req := map[string]string{"username": username, "password": password}
|
||||
body := map[string]interface{}{
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
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 {
|
||||
Token string `json:"token"`
|
||||
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
|
||||
}
|
||||
c.setToken(resp.Token)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
||||
mcias "git.wntrmute.dev/mc/mcias/clients/go"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module git.wntrmute.dev/kyle/mcias/clients/go
|
||||
module git.wntrmute.dev/mc/mcias/clients/go
|
||||
|
||||
go 1.21
|
||||
|
||||
@@ -20,9 +20,13 @@ class Client:
|
||||
ca_cert_path: str | None = None,
|
||||
token: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
service_name: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> None:
|
||||
self._base_url = server_url.rstrip("/")
|
||||
self.token = token
|
||||
self._service_name = service_name
|
||||
self._tags = tags or []
|
||||
ssl_context: ssl.SSLContext | bool
|
||||
if ca_cert_path is not None:
|
||||
ssl_context = ssl.create_default_context(cafile=ca_cert_path)
|
||||
@@ -115,6 +119,9 @@ class Client:
|
||||
) -> tuple[str, str]:
|
||||
"""POST /v1/auth/login — authenticate and obtain a JWT.
|
||||
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] = {
|
||||
"username": username,
|
||||
@@ -122,6 +129,10 @@ class Client:
|
||||
}
|
||||
if totp_code is not None:
|
||||
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)
|
||||
assert data is not None
|
||||
token = str(data["token"])
|
||||
|
||||
@@ -227,6 +227,10 @@ struct LoginRequest<'a> {
|
||||
password: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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)]
|
||||
@@ -268,6 +272,16 @@ pub struct ClientOptions {
|
||||
|
||||
/// Optional pre-existing bearer token.
|
||||
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 ----
|
||||
@@ -280,6 +294,8 @@ pub struct ClientOptions {
|
||||
pub struct Client {
|
||||
base_url: String,
|
||||
http: reqwest::Client,
|
||||
service_name: Option<String>,
|
||||
tags: Vec<String>,
|
||||
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
|
||||
/// Security: the token is never logged or included in error messages.
|
||||
token: Arc<RwLock<Option<String>>>,
|
||||
@@ -306,6 +322,8 @@ impl Client {
|
||||
Ok(Self {
|
||||
base_url: base_url.trim_end_matches('/').to_owned(),
|
||||
http,
|
||||
service_name: opts.service_name,
|
||||
tags: opts.tags,
|
||||
token: Arc::new(RwLock::new(opts.token)),
|
||||
})
|
||||
}
|
||||
@@ -336,6 +354,8 @@ impl Client {
|
||||
username,
|
||||
password,
|
||||
totp_code,
|
||||
service_name: self.service_name.as_deref(),
|
||||
tags: self.tags.clone(),
|
||||
};
|
||||
let resp: TokenResponse = self.post("/v1/auth/login", &body).await?;
|
||||
*self.token.write().await = Some(resp.token.clone());
|
||||
|
||||
1448
cmd/mciasctl/main.go
1448
cmd/mciasctl/main.go
File diff suppressed because it is too large
Load Diff
@@ -6,14 +6,15 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"golang.org/x/term"
|
||||
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
|
||||
"git.wntrmute.dev/mc/mcdsl/terminal"
|
||||
)
|
||||
|
||||
func (t *tool) runAccount(args []string) {
|
||||
if len(args) == 0 {
|
||||
fatalf("account requires a subcommand: list, get, create, set-password, set-status, reset-totp")
|
||||
fatalf("account requires a subcommand: list, get, create, set-password, set-status, reset-totp, reset-webauthn")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
@@ -28,6 +29,8 @@ func (t *tool) runAccount(args []string) {
|
||||
t.accountSetStatus(args[1:])
|
||||
case "reset-totp":
|
||||
t.accountResetTOTP(args[1:])
|
||||
case "reset-webauthn":
|
||||
t.webauthnReset(args[1:])
|
||||
default:
|
||||
fatalf("unknown account subcommand %q", args[0])
|
||||
}
|
||||
@@ -231,20 +234,14 @@ func (t *tool) accountResetTOTP(args []string) {
|
||||
// readPassword reads a password from the terminal without echo.
|
||||
// Falls back to a regular line read if stdin is not a terminal (e.g. in tests).
|
||||
func readPassword(prompt string) (string, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
fd := int(os.Stdin.Fd()) //nolint:gosec // G115: file descriptors are non-negative and fit in int on all supported platforms
|
||||
if term.IsTerminal(fd) {
|
||||
pw, err := term.ReadPassword(fd)
|
||||
fmt.Fprintln(os.Stderr) // newline after hidden input
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read password from terminal: %w", err)
|
||||
}
|
||||
return string(pw), nil
|
||||
pw, err := terminal.ReadPassword(prompt)
|
||||
if err == nil {
|
||||
return pw, nil
|
||||
}
|
||||
// Not a terminal: read a plain line (for piped input in tests).
|
||||
// Fallback for piped input (e.g. tests).
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
var line string
|
||||
_, err := fmt.Fscanln(os.Stdin, &line)
|
||||
if err != nil {
|
||||
if _, err := fmt.Fscanln(os.Stdin, &line); err != nil {
|
||||
return "", fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
return line, nil
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
func (t *tool) runAudit(args []string) {
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
// pgcreds get --id UUID
|
||||
// pgcreds set --id UUID --host H --port P --db D --user U
|
||||
//
|
||||
// rekey
|
||||
// snapshot [--retain-days N]
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -49,9 +49,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -68,6 +68,14 @@ func main() {
|
||||
command := args[0]
|
||||
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
|
||||
// auto-migration on open (a dirty database would prevent the tool from
|
||||
// opening at all, blocking recovery operations like "schema force").
|
||||
@@ -109,6 +117,8 @@ func main() {
|
||||
tool.runAudit(subArgs)
|
||||
case "pgcreds":
|
||||
tool.runPGCreds(subArgs)
|
||||
case "webauthn":
|
||||
tool.runWebAuthn(subArgs)
|
||||
case "rekey":
|
||||
tool.runRekey(subArgs)
|
||||
default:
|
||||
@@ -245,6 +255,11 @@ Commands:
|
||||
account set-password --id UUID (prompts interactively)
|
||||
account set-status --id UUID --status active|inactive|deleted
|
||||
account reset-totp --id UUID
|
||||
account reset-webauthn --id UUID
|
||||
|
||||
webauthn list --id UUID
|
||||
webauthn delete --id UUID --credential-id N
|
||||
webauthn reset --id UUID
|
||||
|
||||
role list --id UUID
|
||||
role grant --id UUID --role ROLE
|
||||
@@ -266,6 +281,11 @@ Commands:
|
||||
rekey Re-encrypt all secrets under a new master passphrase
|
||||
(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
|
||||
file. Use it only when the server is unavailable or for break-glass recovery.
|
||||
All write operations are recorded in the audit log.
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
// newTestTool creates a tool backed by an in-memory SQLite database with a
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
)
|
||||
|
||||
func (t *tool) runPGCreds(args []string) {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
)
|
||||
|
||||
// runRekey re-encrypts all secrets under a new passphrase-derived master key.
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
)
|
||||
|
||||
func (t *tool) runSchema(args []string) {
|
||||
|
||||
44
cmd/mciasdb/snapshot.go
Normal file
44
cmd/mciasdb/snapshot.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||
"git.wntrmute.dev/mc/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)
|
||||
}
|
||||
121
cmd/mciasdb/webauthn.go
Normal file
121
cmd/mciasdb/webauthn.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (t *tool) runWebAuthn(args []string) {
|
||||
if len(args) == 0 {
|
||||
fatalf("webauthn requires a subcommand: list, delete, reset")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
t.webauthnList(args[1:])
|
||||
case "delete":
|
||||
t.webauthnDelete(args[1:])
|
||||
case "reset":
|
||||
t.webauthnReset(args[1:])
|
||||
default:
|
||||
fatalf("unknown webauthn subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tool) webauthnList(args []string) {
|
||||
fs := flag.NewFlagSet("webauthn list", flag.ExitOnError)
|
||||
id := fs.String("id", "", "account UUID (required)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *id == "" {
|
||||
fatalf("webauthn list: --id is required")
|
||||
}
|
||||
|
||||
a, err := t.db.GetAccountByUUID(*id)
|
||||
if err != nil {
|
||||
fatalf("get account: %v", err)
|
||||
}
|
||||
|
||||
creds, err := t.db.GetWebAuthnCredentials(a.ID)
|
||||
if err != nil {
|
||||
fatalf("list webauthn credentials: %v", err)
|
||||
}
|
||||
|
||||
if len(creds) == 0 {
|
||||
fmt.Printf("No WebAuthn credentials for account %s\n", a.Username)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("WebAuthn credentials for %s:\n\n", a.Username)
|
||||
fmt.Printf("%-6s %-20s %-12s %-8s %-20s %-20s\n",
|
||||
"ID", "NAME", "DISCOVERABLE", "COUNT", "CREATED", "LAST USED")
|
||||
fmt.Println(strings.Repeat("-", 96))
|
||||
for _, c := range creds {
|
||||
disc := "no"
|
||||
if c.Discoverable {
|
||||
disc = "yes"
|
||||
}
|
||||
lastUsed := "never"
|
||||
if c.LastUsedAt != nil {
|
||||
lastUsed = c.LastUsedAt.UTC().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
fmt.Printf("%-6d %-20s %-12s %-8d %-20s %-20s\n",
|
||||
c.ID, c.Name, disc, c.SignCount,
|
||||
c.CreatedAt.UTC().Format("2006-01-02 15:04:05"), lastUsed)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tool) webauthnDelete(args []string) {
|
||||
fs := flag.NewFlagSet("webauthn delete", flag.ExitOnError)
|
||||
id := fs.String("id", "", "account UUID (required)")
|
||||
credID := fs.Int64("credential-id", 0, "credential DB row ID (required)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *id == "" || *credID == 0 {
|
||||
fatalf("webauthn delete: --id and --credential-id are required")
|
||||
}
|
||||
|
||||
a, err := t.db.GetAccountByUUID(*id)
|
||||
if err != nil {
|
||||
fatalf("get account: %v", err)
|
||||
}
|
||||
|
||||
if err := t.db.DeleteWebAuthnCredential(*credID, a.ID); err != nil {
|
||||
fatalf("delete webauthn credential: %v", err)
|
||||
}
|
||||
|
||||
if err := t.db.WriteAuditEvent("webauthn_removed", nil, &a.ID, "",
|
||||
fmt.Sprintf(`{"actor":"mciasdb","credential_id":%d}`, *credID)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Printf("WebAuthn credential %d deleted from account %s\n", *credID, a.Username)
|
||||
}
|
||||
|
||||
func (t *tool) webauthnReset(args []string) {
|
||||
fs := flag.NewFlagSet("webauthn reset", flag.ExitOnError)
|
||||
id := fs.String("id", "", "account UUID (required)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *id == "" {
|
||||
fatalf("webauthn reset: --id is required")
|
||||
}
|
||||
|
||||
a, err := t.db.GetAccountByUUID(*id)
|
||||
if err != nil {
|
||||
fatalf("get account: %v", err)
|
||||
}
|
||||
|
||||
count, err := t.db.DeleteAllWebAuthnCredentials(a.ID)
|
||||
if err != nil {
|
||||
fatalf("delete all webauthn credentials: %v", err)
|
||||
}
|
||||
|
||||
if err := t.db.WriteAuditEvent("webauthn_removed", nil, &a.ID, "",
|
||||
fmt.Sprintf(`{"actor":"mciasdb","action":"reset_webauthn","count":%d}`, count)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed %d WebAuthn credential(s) from account %s\n", count, a.Username)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,12 +31,12 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/grpcserver"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/server"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/grpcserver"
|
||||
"git.wntrmute.dev/mc/mcias/internal/server"
|
||||
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -87,17 +87,16 @@ func run(configPath string, logger *slog.Logger) error {
|
||||
masterKey, mkErr := loadMasterKey(cfg, database)
|
||||
if mkErr != nil {
|
||||
// Check if we can start sealed (passphrase mode, empty env var).
|
||||
if cfg.MasterKey.KeyFile == "" && os.Getenv(cfg.MasterKey.PassphraseEnv) == "" {
|
||||
// Verify that this is not a first run — the signing key must already exist.
|
||||
enc, nonce, scErr := database.ReadServerConfig()
|
||||
if scErr != nil || enc == nil || nonce == nil {
|
||||
return fmt.Errorf("first run requires passphrase: %w", mkErr)
|
||||
}
|
||||
v = vault.NewSealed()
|
||||
logger.Info("vault starting in sealed state")
|
||||
} else {
|
||||
if cfg.MasterKey.KeyFile != "" || os.Getenv(cfg.MasterKey.PassphraseEnv) != "" {
|
||||
return fmt.Errorf("load master key: %w", mkErr)
|
||||
}
|
||||
// Verify that this is not a first run — the signing key must already exist.
|
||||
enc, nonce, scErr := database.ReadServerConfig()
|
||||
if scErr != nil || enc == nil || nonce == nil {
|
||||
return fmt.Errorf("first run requires passphrase: %w", mkErr)
|
||||
}
|
||||
v = vault.NewSealed()
|
||||
logger.Info("vault starting in sealed state")
|
||||
} else {
|
||||
// Load or generate the Ed25519 signing key.
|
||||
// Security: The private signing key is stored AES-256-GCM encrypted in the
|
||||
|
||||
@@ -41,3 +41,10 @@ threads = 4
|
||||
|
||||
[master_key]
|
||||
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)"
|
||||
@@ -48,3 +48,14 @@ threads = 4
|
||||
# Set it with: docker run -e MCIAS_MASTER_PASSPHRASE=your-passphrase ...
|
||||
# or with a Docker secret / Kubernetes secret.
|
||||
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"
|
||||
@@ -123,3 +123,24 @@ passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
||||
#
|
||||
# Uncomment and comment out passphrase_env to switch modes.
|
||||
# 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"
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/bin/sh
|
||||
# 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:
|
||||
# 1. Creates the mcias system user and group (idempotent).
|
||||
# 2. Copies binaries to /usr/local/bin/.
|
||||
# 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.
|
||||
#
|
||||
# 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_GROUP="mcias"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
REPO_ROOT="$(dirname "$DEPLOY_DIR")"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -100,11 +101,7 @@ fi
|
||||
# Step 2: Install binaries.
|
||||
info "Installing binaries to $BIN_DIR"
|
||||
for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
|
||||
src="$REPO_ROOT/$bin"
|
||||
if [ ! -f "$src" ]; then
|
||||
# Try bin/ subdirectory (Makefile build output).
|
||||
src="$REPO_ROOT/bin/$bin"
|
||||
fi
|
||||
src="$REPO_ROOT/bin/$bin"
|
||||
if [ ! -f "$src" ]; then
|
||||
warn "Binary not found: $bin — skipping. Run 'make build' first."
|
||||
continue
|
||||
@@ -113,30 +110,40 @@ for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
|
||||
install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin"
|
||||
done
|
||||
|
||||
# Step 3: Create service directory.
|
||||
# Step 3: Create service directory structure.
|
||||
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/certs"
|
||||
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR/backups"
|
||||
|
||||
# Install example config files; never overwrite existing configs.
|
||||
for f in mcias.conf.example mcias.env.example; do
|
||||
src="$SCRIPT_DIR/$f"
|
||||
src="$DEPLOY_DIR/examples/$f"
|
||||
dst="$SRV_DIR/$f"
|
||||
if [ -f "$src" ]; then
|
||||
install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Step 5: Install systemd service unit.
|
||||
# Step 4: Install systemd units.
|
||||
if [ -d "$SYSTEMD_DIR" ]; then
|
||||
info "Installing systemd service unit to $SYSTEMD_DIR"
|
||||
install -m 0644 -o root -g root "$SCRIPT_DIR/mcias.service" "$SYSTEMD_DIR/mcias.service"
|
||||
info "Installing systemd units to $SYSTEMD_DIR"
|
||||
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"
|
||||
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
|
||||
warn "systemd not found at $SYSTEMD_DIR; skipping service unit installation."
|
||||
fi
|
||||
|
||||
# Step 6: Install man pages.
|
||||
# Step 5: Install man pages.
|
||||
if [ -d "$REPO_ROOT/man/man1" ]; then
|
||||
install -d -m 0755 -o root -g root "$MAN_DIR"
|
||||
info "Installing man pages to $MAN_DIR"
|
||||
@@ -170,12 +177,12 @@ Next steps:
|
||||
|
||||
# Self-signed (development / personal use):
|
||||
openssl req -x509 -newkey ed25519 -days 3650 \\
|
||||
-keyout /srv/mcias/server.key \\
|
||||
-out /srv/mcias/server.crt \\
|
||||
-keyout /srv/mcias/certs/server.key \\
|
||||
-out /srv/mcias/certs/server.crt \\
|
||||
-subj "/CN=auth.example.com" \\
|
||||
-nodes
|
||||
chmod 0640 /srv/mcias/server.key
|
||||
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
|
||||
chmod 0640 /srv/mcias/certs/server.key
|
||||
chown mcias:mcias /srv/mcias/certs/server.key /srv/mcias/certs/server.crt
|
||||
|
||||
2. Copy and edit the configuration file:
|
||||
|
||||
@@ -200,6 +207,9 @@ Next steps:
|
||||
systemctl start 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
|
||||
running, or before first start):
|
||||
|
||||
32
deploy/systemd/mcias-backup.service
Normal file
32
deploy/systemd/mcias-backup.service
Normal 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
|
||||
15
deploy/systemd/mcias-backup.timer
Normal file
15
deploy/systemd/mcias-backup.timer
Normal 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
|
||||
@@ -12,7 +12,7 @@ Group=mcias
|
||||
|
||||
# Configuration and secrets.
|
||||
# /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
|
||||
|
||||
ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml
|
||||
@@ -42,6 +42,7 @@ PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1774273680,
|
||||
"narHash": "sha256-a++tZ1RQsDb1I0NHrFwdGuRlR5TORvCEUksM459wKUA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "fdc7b8f7b30fdbedec91b71ed82f36e1637483ed",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
45
flake.nix
Normal file
45
flake.nix
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
description = "mcias - Metacircular Identity and Access Service";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
version = "1.8.0";
|
||||
in
|
||||
{
|
||||
packages.${system} = {
|
||||
default = pkgs.buildGoModule {
|
||||
pname = "mciasctl";
|
||||
inherit version;
|
||||
src = ./.;
|
||||
vendorHash = null;
|
||||
subPackages = [
|
||||
"cmd/mciasctl"
|
||||
"cmd/mciasgrpcctl"
|
||||
];
|
||||
ldflags = [
|
||||
"-s"
|
||||
"-w"
|
||||
"-X main.version=${version}"
|
||||
];
|
||||
postInstall = ''
|
||||
mkdir -p $out/share/zsh/site-functions
|
||||
mkdir -p $out/share/bash-completion/completions
|
||||
mkdir -p $out/share/fish/vendor_completions.d
|
||||
$out/bin/mciasctl completion zsh > $out/share/zsh/site-functions/_mciasctl
|
||||
$out/bin/mciasctl completion bash > $out/share/bash-completion/completions/mciasctl
|
||||
$out/bin/mciasctl completion fish > $out/share/fish/vendor_completions.d/mciasctl.fish
|
||||
$out/bin/mciasgrpcctl completion zsh > $out/share/zsh/site-functions/_mciasgrpcctl
|
||||
$out/bin/mciasgrpcctl completion bash > $out/share/bash-completion/completions/mciasgrpcctl
|
||||
$out/bin/mciasgrpcctl completion fish > $out/share/fish/vendor_completions.d/mciasgrpcctl.fish
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.4
|
||||
// protoc v3.20.3
|
||||
// source: mcias/v1/account.proto
|
||||
|
||||
package mciasv1
|
||||
@@ -1080,7 +1080,7 @@ const file_mcias_v1_account_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\n" +
|
||||
"\n" +
|
||||
"SetPGCreds\x12\x1b.mcias.v1.SetPGCredsRequest\x1a\x1c.mcias.v1.SetPGCredsResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
"SetPGCreds\x12\x1b.mcias.v1.SetPGCredsRequest\x1a\x1c.mcias.v1.SetPGCredsResponseB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_mcias_v1_account_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v6.33.4
|
||||
// - protoc v3.20.3
|
||||
// source: mcias/v1/account.proto
|
||||
|
||||
package mciasv1
|
||||
|
||||
@@ -238,7 +238,7 @@ const file_mcias_v1_admin_proto_rawDesc = "" +
|
||||
"\x01x\x18\x05 \x01(\tR\x01x2\x9a\x01\n" +
|
||||
"\fAdminService\x12;\n" +
|
||||
"\x06Health\x12\x17.mcias.v1.HealthRequest\x1a\x18.mcias.v1.HealthResponse\x12M\n" +
|
||||
"\fGetPublicKey\x12\x1d.mcias.v1.GetPublicKeyRequest\x1a\x1e.mcias.v1.GetPublicKeyResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
"\fGetPublicKey\x12\x1d.mcias.v1.GetPublicKeyRequest\x1a\x1e.mcias.v1.GetPublicKeyResponseB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_mcias_v1_admin_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -569,6 +569,288 @@ func (*RemoveTOTPResponse) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
// ListWebAuthnCredentialsRequest lists metadata for an account's WebAuthn credentials.
|
||||
type ListWebAuthnCredentialsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` // UUID
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsRequest) Reset() {
|
||||
*x = ListWebAuthnCredentialsRequest{}
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ListWebAuthnCredentialsRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ListWebAuthnCredentialsRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ListWebAuthnCredentialsRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ListWebAuthnCredentialsRequest) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsRequest) GetAccountId() string {
|
||||
if x != nil {
|
||||
return x.AccountId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WebAuthnCredentialInfo holds metadata about a stored WebAuthn credential.
|
||||
// Credential material (IDs, public keys) is never included.
|
||||
type WebAuthnCredentialInfo struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Aaguid string `protobuf:"bytes,3,opt,name=aaguid,proto3" json:"aaguid,omitempty"`
|
||||
SignCount uint32 `protobuf:"varint,4,opt,name=sign_count,json=signCount,proto3" json:"sign_count,omitempty"`
|
||||
Discoverable bool `protobuf:"varint,5,opt,name=discoverable,proto3" json:"discoverable,omitempty"`
|
||||
Transports string `protobuf:"bytes,6,opt,name=transports,proto3" json:"transports,omitempty"`
|
||||
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
|
||||
LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) Reset() {
|
||||
*x = WebAuthnCredentialInfo{}
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*WebAuthnCredentialInfo) ProtoMessage() {}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[13]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use WebAuthnCredentialInfo.ProtoReflect.Descriptor instead.
|
||||
func (*WebAuthnCredentialInfo) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{13}
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetId() int64 {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetName() string {
|
||||
if x != nil {
|
||||
return x.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetAaguid() string {
|
||||
if x != nil {
|
||||
return x.Aaguid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetSignCount() uint32 {
|
||||
if x != nil {
|
||||
return x.SignCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetDiscoverable() bool {
|
||||
if x != nil {
|
||||
return x.Discoverable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetTransports() string {
|
||||
if x != nil {
|
||||
return x.Transports
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetCreatedAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.CreatedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *WebAuthnCredentialInfo) GetLastUsedAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.LastUsedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWebAuthnCredentialsResponse returns credential metadata.
|
||||
type ListWebAuthnCredentialsResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Credentials []*WebAuthnCredentialInfo `protobuf:"bytes,1,rep,name=credentials,proto3" json:"credentials,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsResponse) Reset() {
|
||||
*x = ListWebAuthnCredentialsResponse{}
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ListWebAuthnCredentialsResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ListWebAuthnCredentialsResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[14]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ListWebAuthnCredentialsResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ListWebAuthnCredentialsResponse) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{14}
|
||||
}
|
||||
|
||||
func (x *ListWebAuthnCredentialsResponse) GetCredentials() []*WebAuthnCredentialInfo {
|
||||
if x != nil {
|
||||
return x.Credentials
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveWebAuthnCredentialRequest removes a specific WebAuthn credential (admin).
|
||||
type RemoveWebAuthnCredentialRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` // UUID
|
||||
CredentialId int64 `protobuf:"varint,2,opt,name=credential_id,json=credentialId,proto3" json:"credential_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialRequest) Reset() {
|
||||
*x = RemoveWebAuthnCredentialRequest{}
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*RemoveWebAuthnCredentialRequest) ProtoMessage() {}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[15]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use RemoveWebAuthnCredentialRequest.ProtoReflect.Descriptor instead.
|
||||
func (*RemoveWebAuthnCredentialRequest) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{15}
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialRequest) GetAccountId() string {
|
||||
if x != nil {
|
||||
return x.AccountId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialRequest) GetCredentialId() int64 {
|
||||
if x != nil {
|
||||
return x.CredentialId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// RemoveWebAuthnCredentialResponse confirms removal.
|
||||
type RemoveWebAuthnCredentialResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialResponse) Reset() {
|
||||
*x = RemoveWebAuthnCredentialResponse{}
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[16]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*RemoveWebAuthnCredentialResponse) ProtoMessage() {}
|
||||
|
||||
func (x *RemoveWebAuthnCredentialResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mcias_v1_auth_proto_msgTypes[16]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use RemoveWebAuthnCredentialResponse.ProtoReflect.Descriptor instead.
|
||||
func (*RemoveWebAuthnCredentialResponse) Descriptor() ([]byte, []int) {
|
||||
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{16}
|
||||
}
|
||||
|
||||
var File_mcias_v1_auth_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_mcias_v1_auth_proto_rawDesc = "" +
|
||||
@@ -601,7 +883,31 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
|
||||
"\x11RemoveTOTPRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"account_id\x18\x01 \x01(\tR\taccountId\"\x14\n" +
|
||||
"\x12RemoveTOTPResponse2\xab\x03\n" +
|
||||
"\x12RemoveTOTPResponse\"?\n" +
|
||||
"\x1eListWebAuthnCredentialsRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"account_id\x18\x01 \x01(\tR\taccountId\"\xb0\x02\n" +
|
||||
"\x16WebAuthnCredentialInfo\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
|
||||
"\x06aaguid\x18\x03 \x01(\tR\x06aaguid\x12\x1d\n" +
|
||||
"\n" +
|
||||
"sign_count\x18\x04 \x01(\rR\tsignCount\x12\"\n" +
|
||||
"\fdiscoverable\x18\x05 \x01(\bR\fdiscoverable\x12\x1e\n" +
|
||||
"\n" +
|
||||
"transports\x18\x06 \x01(\tR\n" +
|
||||
"transports\x129\n" +
|
||||
"\n" +
|
||||
"created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +
|
||||
"\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" +
|
||||
"lastUsedAt\"e\n" +
|
||||
"\x1fListWebAuthnCredentialsResponse\x12B\n" +
|
||||
"\vcredentials\x18\x01 \x03(\v2 .mcias.v1.WebAuthnCredentialInfoR\vcredentials\"e\n" +
|
||||
"\x1fRemoveWebAuthnCredentialRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"account_id\x18\x01 \x01(\tR\taccountId\x12#\n" +
|
||||
"\rcredential_id\x18\x02 \x01(\x03R\fcredentialId\"\"\n" +
|
||||
" RemoveWebAuthnCredentialResponse2\x8e\x05\n" +
|
||||
"\vAuthService\x128\n" +
|
||||
"\x05Login\x12\x16.mcias.v1.LoginRequest\x1a\x17.mcias.v1.LoginResponse\x12;\n" +
|
||||
"\x06Logout\x12\x17.mcias.v1.LogoutRequest\x1a\x18.mcias.v1.LogoutResponse\x12G\n" +
|
||||
@@ -611,7 +917,9 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
|
||||
"EnrollTOTP\x12\x1b.mcias.v1.EnrollTOTPRequest\x1a\x1c.mcias.v1.EnrollTOTPResponse\x12J\n" +
|
||||
"\vConfirmTOTP\x12\x1c.mcias.v1.ConfirmTOTPRequest\x1a\x1d.mcias.v1.ConfirmTOTPResponse\x12G\n" +
|
||||
"\n" +
|
||||
"RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
"RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponse\x12n\n" +
|
||||
"\x17ListWebAuthnCredentials\x12(.mcias.v1.ListWebAuthnCredentialsRequest\x1a).mcias.v1.ListWebAuthnCredentialsResponse\x12q\n" +
|
||||
"\x18RemoveWebAuthnCredential\x12).mcias.v1.RemoveWebAuthnCredentialRequest\x1a*.mcias.v1.RemoveWebAuthnCredentialResponseB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_mcias_v1_auth_proto_rawDescOnce sync.Once
|
||||
@@ -625,42 +933,54 @@ func file_mcias_v1_auth_proto_rawDescGZIP() []byte {
|
||||
return file_mcias_v1_auth_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_mcias_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
|
||||
var file_mcias_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
|
||||
var file_mcias_v1_auth_proto_goTypes = []any{
|
||||
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
|
||||
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
|
||||
(*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest
|
||||
(*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse
|
||||
(*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest
|
||||
(*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse
|
||||
(*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest
|
||||
(*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse
|
||||
(*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest
|
||||
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
|
||||
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
|
||||
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
|
||||
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
|
||||
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
|
||||
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
|
||||
(*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest
|
||||
(*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse
|
||||
(*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest
|
||||
(*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse
|
||||
(*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest
|
||||
(*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse
|
||||
(*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest
|
||||
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
|
||||
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
|
||||
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
|
||||
(*ListWebAuthnCredentialsRequest)(nil), // 12: mcias.v1.ListWebAuthnCredentialsRequest
|
||||
(*WebAuthnCredentialInfo)(nil), // 13: mcias.v1.WebAuthnCredentialInfo
|
||||
(*ListWebAuthnCredentialsResponse)(nil), // 14: mcias.v1.ListWebAuthnCredentialsResponse
|
||||
(*RemoveWebAuthnCredentialRequest)(nil), // 15: mcias.v1.RemoveWebAuthnCredentialRequest
|
||||
(*RemoveWebAuthnCredentialResponse)(nil), // 16: mcias.v1.RemoveWebAuthnCredentialResponse
|
||||
(*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp
|
||||
}
|
||||
var file_mcias_v1_auth_proto_depIdxs = []int32{
|
||||
12, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||
12, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||
0, // 2: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
|
||||
2, // 3: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
|
||||
4, // 4: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
|
||||
6, // 5: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
|
||||
8, // 6: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
|
||||
10, // 7: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
|
||||
1, // 8: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
|
||||
3, // 9: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
|
||||
5, // 10: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
|
||||
7, // 11: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
|
||||
9, // 12: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
|
||||
11, // 13: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse
|
||||
8, // [8:14] is the sub-list for method output_type
|
||||
2, // [2:8] is the sub-list for method input_type
|
||||
2, // [2:2] is the sub-list for extension type_name
|
||||
2, // [2:2] is the sub-list for extension extendee
|
||||
0, // [0:2] is the sub-list for field type_name
|
||||
17, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||
17, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||
17, // 2: mcias.v1.WebAuthnCredentialInfo.created_at:type_name -> google.protobuf.Timestamp
|
||||
17, // 3: mcias.v1.WebAuthnCredentialInfo.last_used_at:type_name -> google.protobuf.Timestamp
|
||||
13, // 4: mcias.v1.ListWebAuthnCredentialsResponse.credentials:type_name -> mcias.v1.WebAuthnCredentialInfo
|
||||
0, // 5: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
|
||||
2, // 6: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
|
||||
4, // 7: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
|
||||
6, // 8: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
|
||||
8, // 9: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
|
||||
10, // 10: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
|
||||
12, // 11: mcias.v1.AuthService.ListWebAuthnCredentials:input_type -> mcias.v1.ListWebAuthnCredentialsRequest
|
||||
15, // 12: mcias.v1.AuthService.RemoveWebAuthnCredential:input_type -> mcias.v1.RemoveWebAuthnCredentialRequest
|
||||
1, // 13: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
|
||||
3, // 14: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
|
||||
5, // 15: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
|
||||
7, // 16: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
|
||||
9, // 17: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
|
||||
11, // 18: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse
|
||||
14, // 19: mcias.v1.AuthService.ListWebAuthnCredentials:output_type -> mcias.v1.ListWebAuthnCredentialsResponse
|
||||
16, // 20: mcias.v1.AuthService.RemoveWebAuthnCredential:output_type -> mcias.v1.RemoveWebAuthnCredentialResponse
|
||||
13, // [13:21] is the sub-list for method output_type
|
||||
5, // [5:13] is the sub-list for method input_type
|
||||
5, // [5:5] is the sub-list for extension type_name
|
||||
5, // [5:5] is the sub-list for extension extendee
|
||||
0, // [0:5] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_mcias_v1_auth_proto_init() }
|
||||
@@ -674,7 +994,7 @@ func file_mcias_v1_auth_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 12,
|
||||
NumMessages: 17,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -21,12 +21,14 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login"
|
||||
AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout"
|
||||
AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken"
|
||||
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
|
||||
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
|
||||
AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP"
|
||||
AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login"
|
||||
AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout"
|
||||
AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken"
|
||||
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
|
||||
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
|
||||
AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP"
|
||||
AuthService_ListWebAuthnCredentials_FullMethodName = "/mcias.v1.AuthService/ListWebAuthnCredentials"
|
||||
AuthService_RemoveWebAuthnCredential_FullMethodName = "/mcias.v1.AuthService/RemoveWebAuthnCredential"
|
||||
)
|
||||
|
||||
// AuthServiceClient is the client API for AuthService service.
|
||||
@@ -53,6 +55,12 @@ type AuthServiceClient interface {
|
||||
// RemoveTOTP removes TOTP from an account (admin only).
|
||||
// Requires: admin JWT in metadata.
|
||||
RemoveTOTP(ctx context.Context, in *RemoveTOTPRequest, opts ...grpc.CallOption) (*RemoveTOTPResponse, error)
|
||||
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
|
||||
// Requires: admin JWT in metadata.
|
||||
ListWebAuthnCredentials(ctx context.Context, in *ListWebAuthnCredentialsRequest, opts ...grpc.CallOption) (*ListWebAuthnCredentialsResponse, error)
|
||||
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
|
||||
// Requires: admin JWT in metadata.
|
||||
RemoveWebAuthnCredential(ctx context.Context, in *RemoveWebAuthnCredentialRequest, opts ...grpc.CallOption) (*RemoveWebAuthnCredentialResponse, error)
|
||||
}
|
||||
|
||||
type authServiceClient struct {
|
||||
@@ -123,6 +131,26 @@ func (c *authServiceClient) RemoveTOTP(ctx context.Context, in *RemoveTOTPReques
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *authServiceClient) ListWebAuthnCredentials(ctx context.Context, in *ListWebAuthnCredentialsRequest, opts ...grpc.CallOption) (*ListWebAuthnCredentialsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListWebAuthnCredentialsResponse)
|
||||
err := c.cc.Invoke(ctx, AuthService_ListWebAuthnCredentials_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *authServiceClient) RemoveWebAuthnCredential(ctx context.Context, in *RemoveWebAuthnCredentialRequest, opts ...grpc.CallOption) (*RemoveWebAuthnCredentialResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RemoveWebAuthnCredentialResponse)
|
||||
err := c.cc.Invoke(ctx, AuthService_RemoveWebAuthnCredential_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AuthServiceServer is the server API for AuthService service.
|
||||
// All implementations must embed UnimplementedAuthServiceServer
|
||||
// for forward compatibility.
|
||||
@@ -147,6 +175,12 @@ type AuthServiceServer interface {
|
||||
// RemoveTOTP removes TOTP from an account (admin only).
|
||||
// Requires: admin JWT in metadata.
|
||||
RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error)
|
||||
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
|
||||
// Requires: admin JWT in metadata.
|
||||
ListWebAuthnCredentials(context.Context, *ListWebAuthnCredentialsRequest) (*ListWebAuthnCredentialsResponse, error)
|
||||
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
|
||||
// Requires: admin JWT in metadata.
|
||||
RemoveWebAuthnCredential(context.Context, *RemoveWebAuthnCredentialRequest) (*RemoveWebAuthnCredentialResponse, error)
|
||||
mustEmbedUnimplementedAuthServiceServer()
|
||||
}
|
||||
|
||||
@@ -175,6 +209,12 @@ func (UnimplementedAuthServiceServer) ConfirmTOTP(context.Context, *ConfirmTOTPR
|
||||
func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method RemoveTOTP not implemented")
|
||||
}
|
||||
func (UnimplementedAuthServiceServer) ListWebAuthnCredentials(context.Context, *ListWebAuthnCredentialsRequest) (*ListWebAuthnCredentialsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListWebAuthnCredentials not implemented")
|
||||
}
|
||||
func (UnimplementedAuthServiceServer) RemoveWebAuthnCredential(context.Context, *RemoveWebAuthnCredentialRequest) (*RemoveWebAuthnCredentialResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method RemoveWebAuthnCredential not implemented")
|
||||
}
|
||||
func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {}
|
||||
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -304,6 +344,42 @@ func _AuthService_RemoveTOTP_Handler(srv interface{}, ctx context.Context, dec f
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AuthService_ListWebAuthnCredentials_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListWebAuthnCredentialsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AuthServiceServer).ListWebAuthnCredentials(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AuthService_ListWebAuthnCredentials_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AuthServiceServer).ListWebAuthnCredentials(ctx, req.(*ListWebAuthnCredentialsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AuthService_RemoveWebAuthnCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RemoveWebAuthnCredentialRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AuthServiceServer).RemoveWebAuthnCredential(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AuthService_RemoveWebAuthnCredential_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AuthServiceServer).RemoveWebAuthnCredential(ctx, req.(*RemoveWebAuthnCredentialRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -335,6 +411,14 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "RemoveTOTP",
|
||||
Handler: _AuthService_RemoveTOTP_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListWebAuthnCredentials",
|
||||
Handler: _AuthService_ListWebAuthnCredentials_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RemoveWebAuthnCredential",
|
||||
Handler: _AuthService_RemoveWebAuthnCredential_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "mcias/v1/auth.proto",
|
||||
|
||||
@@ -349,7 +349,7 @@ const file_mcias_v1_common_proto_rawDesc = "" +
|
||||
"\x04port\x18\x05 \x01(\x05R\x04port\"5\n" +
|
||||
"\x05Error\x12\x18\n" +
|
||||
"\amessage\x18\x01 \x01(\tR\amessage\x12\x12\n" +
|
||||
"\x04code\x18\x02 \x01(\tR\x04codeB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
"\x04code\x18\x02 \x01(\tR\x04codeB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_mcias_v1_common_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -703,7 +703,7 @@ const file_mcias_v1_policy_proto_rawDesc = "" +
|
||||
"\x10CreatePolicyRule\x12!.mcias.v1.CreatePolicyRuleRequest\x1a\".mcias.v1.CreatePolicyRuleResponse\x12P\n" +
|
||||
"\rGetPolicyRule\x12\x1e.mcias.v1.GetPolicyRuleRequest\x1a\x1f.mcias.v1.GetPolicyRuleResponse\x12Y\n" +
|
||||
"\x10UpdatePolicyRule\x12!.mcias.v1.UpdatePolicyRuleRequest\x1a\".mcias.v1.UpdatePolicyRuleResponse\x12Y\n" +
|
||||
"\x10DeletePolicyRule\x12!.mcias.v1.DeletePolicyRuleRequest\x1a\".mcias.v1.DeletePolicyRuleResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
"\x10DeletePolicyRule\x12!.mcias.v1.DeletePolicyRuleRequest\x1a\".mcias.v1.DeletePolicyRuleResponseB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_mcias_v1_policy_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -346,7 +346,7 @@ const file_mcias_v1_token_proto_rawDesc = "" +
|
||||
"\fTokenService\x12P\n" +
|
||||
"\rValidateToken\x12\x1e.mcias.v1.ValidateTokenRequest\x1a\x1f.mcias.v1.ValidateTokenResponse\x12\\\n" +
|
||||
"\x11IssueServiceToken\x12\".mcias.v1.IssueServiceTokenRequest\x1a#.mcias.v1.IssueServiceTokenResponse\x12J\n" +
|
||||
"\vRevokeToken\x12\x1c.mcias.v1.RevokeTokenRequest\x1a\x1d.mcias.v1.RevokeTokenResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
"\vRevokeToken\x12\x1c.mcias.v1.RevokeTokenRequest\x1a\x1d.mcias.v1.RevokeTokenResponseB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_mcias_v1_token_proto_rawDescOnce sync.Once
|
||||
|
||||
36
go.mod
36
go.mod
@@ -1,30 +1,40 @@
|
||||
module git.wntrmute.dev/kyle/mcias
|
||||
module git.wntrmute.dev/mc/mcias
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.wntrmute.dev/mc/mcdsl v1.4.0
|
||||
github.com/go-webauthn/webauthn v0.16.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/term v0.37.0
|
||||
google.golang.org/grpc v1.74.2
|
||||
google.golang.org/protobuf v1.36.7
|
||||
modernc.org/sqlite v1.46.1
|
||||
github.com/pelletier/go-toml/v2 v2.3.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.49.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.10
|
||||
modernc.org/sqlite v1.47.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.2 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
132
go.sum
132
go.sum
@@ -1,11 +1,24 @@
|
||||
git.wntrmute.dev/mc/mcdsl v1.4.0 h1:PsEIyskcjBduwHSRwNB/U/uSeU/cv3C8MVr0SRjBRLg=
|
||||
git.wntrmute.dev/mc/mcdsl v1.4.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
|
||||
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
|
||||
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
|
||||
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
@@ -14,79 +27,98 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
|
||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -95,8 +127,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
func TestJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
verify func(t *testing.T, result string)
|
||||
name string
|
||||
pairs []string
|
||||
verify func(t *testing.T, result string)
|
||||
}{
|
||||
{
|
||||
name: "single pair",
|
||||
@@ -109,9 +109,9 @@ func TestJSON(t *testing.T) {
|
||||
|
||||
func TestJSONWithRoles(t *testing.T) {
|
||||
tests := []struct {
|
||||
verify func(t *testing.T, result string)
|
||||
name string
|
||||
roles []string
|
||||
verify func(t *testing.T, result string)
|
||||
}{
|
||||
{
|
||||
name: "multiple roles",
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
)
|
||||
|
||||
// ErrInvalidCredentials is returned for any authentication failure.
|
||||
|
||||
@@ -8,18 +8,29 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
// Config is the top-level configuration structure parsed from the TOML file.
|
||||
type Config struct {
|
||||
type Config struct { //nolint:govet // fieldalignment: TOML section order is more readable
|
||||
Server ServerConfig `toml:"server"`
|
||||
MasterKey MasterKeyConfig `toml:"master_key"`
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
Tokens TokensConfig `toml:"tokens"`
|
||||
Argon2 Argon2Config `toml:"argon2"`
|
||||
WebAuthn WebAuthnConfig `toml:"webauthn"`
|
||||
}
|
||||
|
||||
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
|
||||
// section disables WebAuthn support. If any field is set, RPID and RPOrigin are
|
||||
// required and RPOrigin must use the HTTPS scheme.
|
||||
type WebAuthnConfig struct {
|
||||
RPID string `toml:"rp_id"`
|
||||
RPOrigin string `toml:"rp_origin"`
|
||||
DisplayName string `toml:"display_name"`
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP listener and TLS settings.
|
||||
@@ -174,8 +185,8 @@ func (c *Config) validate() error {
|
||||
// generous to accommodate a range of legitimate deployments while
|
||||
// catching obvious typos (e.g. "876000h" instead of "8760h").
|
||||
const (
|
||||
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
|
||||
maxAdminExpiry = 24 * time.Hour // 24 hours
|
||||
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
|
||||
maxAdminExpiry = 24 * time.Hour // 24 hours
|
||||
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
|
||||
)
|
||||
if c.Tokens.DefaultExpiry.Duration <= 0 {
|
||||
@@ -222,6 +233,19 @@ func (c *Config) validate() error {
|
||||
errs = append(errs, errors.New("master_key: only one of passphrase_env or keyfile may be set"))
|
||||
}
|
||||
|
||||
// WebAuthn — if any field is set, RPID and RPOrigin are required.
|
||||
hasWebAuthn := c.WebAuthn.RPID != "" || c.WebAuthn.RPOrigin != "" || c.WebAuthn.DisplayName != ""
|
||||
if hasWebAuthn {
|
||||
if c.WebAuthn.RPID == "" {
|
||||
errs = append(errs, errors.New("webauthn.rp_id is required when webauthn is configured"))
|
||||
}
|
||||
if c.WebAuthn.RPOrigin == "" {
|
||||
errs = append(errs, errors.New("webauthn.rp_origin is required when webauthn is configured"))
|
||||
} else if !strings.HasPrefix(c.WebAuthn.RPOrigin, "https://") {
|
||||
errs = append(errs, fmt.Errorf("webauthn.rp_origin must use the https:// scheme (got %q)", c.WebAuthn.RPOrigin))
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -233,3 +257,8 @@ func (c *Config) AdminExpiry() time.Duration { return c.Tokens.AdminExpiry.Durat
|
||||
|
||||
// ServiceExpiry returns the configured service token expiry duration.
|
||||
func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.Duration }
|
||||
|
||||
// WebAuthnEnabled reports whether WebAuthn/passkey support is configured.
|
||||
func (c *Config) WebAuthnEnabled() bool {
|
||||
return c.WebAuthn.RPID != "" && c.WebAuthn.RPOrigin != ""
|
||||
}
|
||||
|
||||
@@ -213,9 +213,9 @@ threads = 4
|
||||
// TestTrustedProxyValidation verifies that trusted_proxy must be a valid IP.
|
||||
func TestTrustedProxyValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
proxy string
|
||||
wantErr bool
|
||||
name string
|
||||
proxy string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty is valid (disabled)", "", false},
|
||||
{"valid IPv4", "127.0.0.1", false},
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
// openTestDB opens an in-memory SQLite database for testing.
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
// openTestDB is defined in db_test.go in this package; reused here.
|
||||
|
||||
@@ -22,7 +22,7 @@ var migrationsFS embed.FS
|
||||
// LatestSchemaVersion is the highest migration version defined in the
|
||||
// migrations/ directory. Update this constant whenever a new migration file
|
||||
// is added.
|
||||
const LatestSchemaVersion = 7
|
||||
const LatestSchemaVersion = 9
|
||||
|
||||
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
|
||||
// files. It opens a dedicated *sql.DB using the same DSN as the main
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS webauthn_credentials;
|
||||
18
internal/db/migrations/000009_webauthn_credentials.up.sql
Normal file
18
internal/db/migrations/000009_webauthn_credentials.up.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
credential_id_enc BLOB NOT NULL,
|
||||
credential_id_nonce BLOB NOT NULL,
|
||||
public_key_enc BLOB NOT NULL,
|
||||
public_key_nonce BLOB NOT NULL,
|
||||
aaguid TEXT NOT NULL DEFAULT '',
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
discoverable INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webauthn_credentials_account_id ON webauthn_credentials(account_id);
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
// ListCredentialedAccountIDs returns the set of account IDs that already have
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
// policyRuleCols is the column list for all policy rule SELECT queries.
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
func TestCreateAndGetPolicyRule(t *testing.T) {
|
||||
|
||||
68
internal/db/snapshot.go
Normal file
68
internal/db/snapshot.go
Normal 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
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package db
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
func TestGetAccountTags_Empty(t *testing.T) {
|
||||
|
||||
208
internal/db/webauthn.go
Normal file
208
internal/db/webauthn.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
// CreateWebAuthnCredential inserts a new WebAuthn credential record.
|
||||
// All encrypted fields (credential_id, public_key) must be encrypted by the caller.
|
||||
func (db *DB) CreateWebAuthnCredential(cred *model.WebAuthnCredential) (int64, error) {
|
||||
n := now()
|
||||
result, err := db.sql.Exec(`
|
||||
INSERT INTO webauthn_credentials
|
||||
(account_id, name, credential_id_enc, credential_id_nonce,
|
||||
public_key_enc, public_key_nonce, aaguid, sign_count,
|
||||
discoverable, transports, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
cred.AccountID, cred.Name, cred.CredentialIDEnc, cred.CredentialIDNonce,
|
||||
cred.PublicKeyEnc, cred.PublicKeyNonce, cred.AAGUID, cred.SignCount,
|
||||
boolToInt(cred.Discoverable), cred.Transports, n, n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: create webauthn credential: %w", err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: webauthn credential last insert id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetWebAuthnCredentials returns all WebAuthn credentials for an account.
|
||||
func (db *DB) GetWebAuthnCredentials(accountID int64) ([]*model.WebAuthnCredential, error) {
|
||||
rows, err := db.sql.Query(`
|
||||
SELECT id, account_id, name, credential_id_enc, credential_id_nonce,
|
||||
public_key_enc, public_key_nonce, aaguid, sign_count,
|
||||
discoverable, transports, created_at, updated_at, last_used_at
|
||||
FROM webauthn_credentials WHERE account_id = ? ORDER BY created_at ASC`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list webauthn credentials: %w", err)
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck // rows.Close error is non-fatal
|
||||
return scanWebAuthnCredentials(rows)
|
||||
}
|
||||
|
||||
// GetWebAuthnCredentialByID returns a single WebAuthn credential by its DB row ID.
|
||||
// Returns ErrNotFound if the credential does not exist.
|
||||
func (db *DB) GetWebAuthnCredentialByID(id int64) (*model.WebAuthnCredential, error) {
|
||||
row := db.sql.QueryRow(`
|
||||
SELECT id, account_id, name, credential_id_enc, credential_id_nonce,
|
||||
public_key_enc, public_key_nonce, aaguid, sign_count,
|
||||
discoverable, transports, created_at, updated_at, last_used_at
|
||||
FROM webauthn_credentials WHERE id = ?`, id)
|
||||
return scanWebAuthnCredential(row)
|
||||
}
|
||||
|
||||
// DeleteWebAuthnCredential deletes a WebAuthn credential by ID, verifying ownership.
|
||||
// Returns ErrNotFound if the credential does not exist or does not belong to the account.
|
||||
func (db *DB) DeleteWebAuthnCredential(id, accountID int64) error {
|
||||
result, err := db.sql.Exec(
|
||||
`DELETE FROM webauthn_credentials WHERE id = ? AND account_id = ?`, id, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: delete webauthn credential: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: webauthn delete rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWebAuthnCredentialAdmin deletes a WebAuthn credential by ID without ownership check.
|
||||
func (db *DB) DeleteWebAuthnCredentialAdmin(id int64) error {
|
||||
result, err := db.sql.Exec(`DELETE FROM webauthn_credentials WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: admin delete webauthn credential: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: webauthn admin delete rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllWebAuthnCredentials removes all WebAuthn credentials for an account.
|
||||
func (db *DB) DeleteAllWebAuthnCredentials(accountID int64) (int64, error) {
|
||||
result, err := db.sql.Exec(
|
||||
`DELETE FROM webauthn_credentials WHERE account_id = ?`, accountID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: delete all webauthn credentials: %w", err)
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// UpdateWebAuthnSignCount updates the sign counter for a credential.
|
||||
func (db *DB) UpdateWebAuthnSignCount(id int64, signCount uint32) error {
|
||||
_, err := db.sql.Exec(
|
||||
`UPDATE webauthn_credentials SET sign_count = ?, updated_at = ? WHERE id = ?`,
|
||||
signCount, now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: update webauthn sign count: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateWebAuthnLastUsed sets the last_used_at timestamp for a credential.
|
||||
func (db *DB) UpdateWebAuthnLastUsed(id int64) error {
|
||||
_, err := db.sql.Exec(
|
||||
`UPDATE webauthn_credentials SET last_used_at = ?, updated_at = ? WHERE id = ?`,
|
||||
now(), now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: update webauthn last used: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasWebAuthnCredentials reports whether the account has any WebAuthn credentials.
|
||||
func (db *DB) HasWebAuthnCredentials(accountID int64) (bool, error) {
|
||||
var count int
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT COUNT(*) FROM webauthn_credentials WHERE account_id = ?`, accountID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("db: count webauthn credentials: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CountWebAuthnCredentials returns the number of WebAuthn credentials for an account.
|
||||
func (db *DB) CountWebAuthnCredentials(accountID int64) (int, error) {
|
||||
var count int
|
||||
err := db.sql.QueryRow(
|
||||
`SELECT COUNT(*) FROM webauthn_credentials WHERE account_id = ?`, accountID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: count webauthn credentials: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// boolToInt converts a bool to 0/1 for SQLite storage.
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func scanWebAuthnCredentials(rows *sql.Rows) ([]*model.WebAuthnCredential, error) {
|
||||
var creds []*model.WebAuthnCredential
|
||||
for rows.Next() {
|
||||
cred, err := scanWebAuthnRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds = append(creds, cred)
|
||||
}
|
||||
return creds, rows.Err()
|
||||
}
|
||||
|
||||
// scannable is implemented by both *sql.Row and *sql.Rows.
|
||||
type scannable interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanWebAuthnRow(s scannable) (*model.WebAuthnCredential, error) {
|
||||
var cred model.WebAuthnCredential
|
||||
var createdAt, updatedAt string
|
||||
var lastUsedAt *string
|
||||
var discoverable int
|
||||
err := s.Scan(
|
||||
&cred.ID, &cred.AccountID, &cred.Name,
|
||||
&cred.CredentialIDEnc, &cred.CredentialIDNonce,
|
||||
&cred.PublicKeyEnc, &cred.PublicKeyNonce,
|
||||
&cred.AAGUID, &cred.SignCount,
|
||||
&discoverable, &cred.Transports,
|
||||
&createdAt, &updatedAt, &lastUsedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("db: scan webauthn credential: %w", err)
|
||||
}
|
||||
cred.Discoverable = discoverable != 0
|
||||
cred.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred.UpdatedAt, err = parseTime(updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred.LastUsedAt, err = nullableTime(lastUsedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func scanWebAuthnCredential(row *sql.Row) (*model.WebAuthnCredential, error) {
|
||||
return scanWebAuthnRow(row)
|
||||
}
|
||||
251
internal/db/webauthn_test.go
Normal file
251
internal/db/webauthn_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
func TestWebAuthnCRUD(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
acct, err := database.CreateAccount("webauthnuser", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
// Empty state.
|
||||
has, err := database.HasWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("has credentials: %v", err)
|
||||
}
|
||||
if has {
|
||||
t.Error("expected no credentials")
|
||||
}
|
||||
|
||||
count, err := database.CountWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("count credentials: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 credentials, got %d", count)
|
||||
}
|
||||
|
||||
creds, err := database.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get credentials (empty): %v", err)
|
||||
}
|
||||
if len(creds) != 0 {
|
||||
t.Errorf("expected 0 credentials, got %d", len(creds))
|
||||
}
|
||||
|
||||
// Create credential.
|
||||
cred := &model.WebAuthnCredential{
|
||||
AccountID: acct.ID,
|
||||
Name: "Test Key",
|
||||
CredentialIDEnc: []byte("enc-cred-id"),
|
||||
CredentialIDNonce: []byte("nonce-cred-id"),
|
||||
PublicKeyEnc: []byte("enc-pubkey"),
|
||||
PublicKeyNonce: []byte("nonce-pubkey"),
|
||||
AAGUID: "2fc0579f811347eab116bb5a8db9202a",
|
||||
SignCount: 0,
|
||||
Discoverable: true,
|
||||
Transports: "usb,nfc",
|
||||
}
|
||||
id, err := database.CreateWebAuthnCredential(cred)
|
||||
if err != nil {
|
||||
t.Fatalf("create credential: %v", err)
|
||||
}
|
||||
if id == 0 {
|
||||
t.Error("expected non-zero credential ID")
|
||||
}
|
||||
|
||||
// Now has credentials.
|
||||
has, err = database.HasWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("has credentials after create: %v", err)
|
||||
}
|
||||
if !has {
|
||||
t.Error("expected credentials to exist")
|
||||
}
|
||||
|
||||
count, err = database.CountWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("count after create: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 credential, got %d", count)
|
||||
}
|
||||
|
||||
// Get by ID.
|
||||
got, err := database.GetWebAuthnCredentialByID(id)
|
||||
if err != nil {
|
||||
t.Fatalf("get by ID: %v", err)
|
||||
}
|
||||
if got.Name != "Test Key" {
|
||||
t.Errorf("Name = %q, want %q", got.Name, "Test Key")
|
||||
}
|
||||
if !got.Discoverable {
|
||||
t.Error("expected discoverable=true")
|
||||
}
|
||||
if got.Transports != "usb,nfc" {
|
||||
t.Errorf("Transports = %q, want %q", got.Transports, "usb,nfc")
|
||||
}
|
||||
if got.AccountID != acct.ID {
|
||||
t.Errorf("AccountID = %d, want %d", got.AccountID, acct.ID)
|
||||
}
|
||||
|
||||
// Get list.
|
||||
creds, err = database.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get credentials: %v", err)
|
||||
}
|
||||
if len(creds) != 1 {
|
||||
t.Fatalf("expected 1 credential, got %d", len(creds))
|
||||
}
|
||||
if creds[0].ID != id {
|
||||
t.Errorf("credential ID = %d, want %d", creds[0].ID, id)
|
||||
}
|
||||
|
||||
// Update sign count.
|
||||
if err := database.UpdateWebAuthnSignCount(id, 5); err != nil {
|
||||
t.Fatalf("update sign count: %v", err)
|
||||
}
|
||||
got, _ = database.GetWebAuthnCredentialByID(id)
|
||||
if got.SignCount != 5 {
|
||||
t.Errorf("SignCount = %d, want 5", got.SignCount)
|
||||
}
|
||||
|
||||
// Update last used.
|
||||
if err := database.UpdateWebAuthnLastUsed(id); err != nil {
|
||||
t.Fatalf("update last used: %v", err)
|
||||
}
|
||||
got, _ = database.GetWebAuthnCredentialByID(id)
|
||||
if got.LastUsedAt == nil {
|
||||
t.Error("expected LastUsedAt to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnDeleteOwnership(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
acct1, _ := database.CreateAccount("wa1", model.AccountTypeHuman, "hash")
|
||||
acct2, _ := database.CreateAccount("wa2", model.AccountTypeHuman, "hash")
|
||||
|
||||
cred := &model.WebAuthnCredential{
|
||||
AccountID: acct1.ID,
|
||||
Name: "Key",
|
||||
CredentialIDEnc: []byte("enc"),
|
||||
CredentialIDNonce: []byte("nonce"),
|
||||
PublicKeyEnc: []byte("enc"),
|
||||
PublicKeyNonce: []byte("nonce"),
|
||||
}
|
||||
id, _ := database.CreateWebAuthnCredential(cred)
|
||||
|
||||
// Delete with wrong owner should fail.
|
||||
err := database.DeleteWebAuthnCredential(id, acct2.ID)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound for wrong owner, got %v", err)
|
||||
}
|
||||
|
||||
// Delete with correct owner succeeds.
|
||||
if err := database.DeleteWebAuthnCredential(id, acct1.ID); err != nil {
|
||||
t.Fatalf("delete with correct owner: %v", err)
|
||||
}
|
||||
|
||||
// Verify gone.
|
||||
_, err = database.GetWebAuthnCredentialByID(id)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnDeleteAdmin(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
acct, _ := database.CreateAccount("waadmin", model.AccountTypeHuman, "hash")
|
||||
cred := &model.WebAuthnCredential{
|
||||
AccountID: acct.ID,
|
||||
Name: "Key",
|
||||
CredentialIDEnc: []byte("enc"),
|
||||
CredentialIDNonce: []byte("nonce"),
|
||||
PublicKeyEnc: []byte("enc"),
|
||||
PublicKeyNonce: []byte("nonce"),
|
||||
}
|
||||
id, _ := database.CreateWebAuthnCredential(cred)
|
||||
|
||||
// Admin delete (no ownership check).
|
||||
if err := database.DeleteWebAuthnCredentialAdmin(id); err != nil {
|
||||
t.Fatalf("admin delete: %v", err)
|
||||
}
|
||||
|
||||
// Non-existent should return ErrNotFound.
|
||||
if err := database.DeleteWebAuthnCredentialAdmin(id); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound for non-existent, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnDeleteAll(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
acct, _ := database.CreateAccount("wada", model.AccountTypeHuman, "hash")
|
||||
|
||||
for i := range 3 {
|
||||
cred := &model.WebAuthnCredential{
|
||||
AccountID: acct.ID,
|
||||
Name: "Key",
|
||||
CredentialIDEnc: []byte{byte(i)},
|
||||
CredentialIDNonce: []byte("n"),
|
||||
PublicKeyEnc: []byte{byte(i)},
|
||||
PublicKeyNonce: []byte("n"),
|
||||
}
|
||||
if _, err := database.CreateWebAuthnCredential(cred); err != nil {
|
||||
t.Fatalf("create %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
deleted, err := database.DeleteAllWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("delete all: %v", err)
|
||||
}
|
||||
if deleted != 3 {
|
||||
t.Errorf("expected 3 deleted, got %d", deleted)
|
||||
}
|
||||
|
||||
count, _ := database.CountWebAuthnCredentials(acct.ID)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 after delete all, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnCascadeDelete(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
acct, _ := database.CreateAccount("wacascade", model.AccountTypeHuman, "hash")
|
||||
cred := &model.WebAuthnCredential{
|
||||
AccountID: acct.ID,
|
||||
Name: "Key",
|
||||
CredentialIDEnc: []byte("enc"),
|
||||
CredentialIDNonce: []byte("nonce"),
|
||||
PublicKeyEnc: []byte("enc"),
|
||||
PublicKeyNonce: []byte("nonce"),
|
||||
}
|
||||
id, _ := database.CreateWebAuthnCredential(cred)
|
||||
|
||||
// Delete the account — credentials should cascade.
|
||||
if err := database.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
|
||||
t.Fatalf("update status: %v", err)
|
||||
}
|
||||
|
||||
// The credential should still be retrievable (soft delete on account doesn't cascade).
|
||||
// But if we hard-delete via SQL, the FK cascade should clean up.
|
||||
// For now just verify the credential still exists after a status change.
|
||||
got, err := database.GetWebAuthnCredentialByID(id)
|
||||
if err != nil {
|
||||
t.Fatalf("get after account status change: %v", err)
|
||||
}
|
||||
if got.ID != id {
|
||||
t.Errorf("credential ID = %d, want %d", got.ID, id)
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/validate"
|
||||
)
|
||||
|
||||
type accountServiceServer struct {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||
)
|
||||
|
||||
type adminServiceServer struct {
|
||||
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||
)
|
||||
|
||||
type authServiceServer struct {
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
type credentialServiceServer struct {
|
||||
|
||||
@@ -30,11 +30,11 @@ import (
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||
)
|
||||
|
||||
// contextKey is the unexported context key type for this package.
|
||||
|
||||
@@ -24,13 +24,13 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
|
||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||
)
|
||||
|
||||
type policyServiceServer struct {
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||
)
|
||||
|
||||
type tokenServiceServer struct {
|
||||
|
||||
92
internal/grpcserver/webauthn.go
Normal file
92
internal/grpcserver/webauthn.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// WebAuthn gRPC handlers for listing and removing WebAuthn credentials.
|
||||
// These are admin-only operations that mirror the REST handlers in
|
||||
// internal/server/handlers_webauthn.go.
|
||||
package grpcserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
|
||||
// Requires: admin JWT in metadata.
|
||||
//
|
||||
// Security: credential material (IDs, public keys) is never included in the
|
||||
// response — only metadata (name, sign count, timestamps, etc.).
|
||||
func (a *authServiceServer) ListWebAuthnCredentials(ctx context.Context, req *mciasv1.ListWebAuthnCredentialsRequest) (*mciasv1.ListWebAuthnCredentialsResponse, error) {
|
||||
if err := a.s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.AccountId == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "account_id is required")
|
||||
}
|
||||
|
||||
acct, err := a.s.db.GetAccountByUUID(req.AccountId)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, "account not found")
|
||||
}
|
||||
|
||||
creds, err := a.s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
a.s.logger.Error("list webauthn credentials", "error", err, "account_id", acct.ID)
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
resp := &mciasv1.ListWebAuthnCredentialsResponse{
|
||||
Credentials: make([]*mciasv1.WebAuthnCredentialInfo, 0, len(creds)),
|
||||
}
|
||||
for _, c := range creds {
|
||||
info := &mciasv1.WebAuthnCredentialInfo{
|
||||
Id: c.ID,
|
||||
Name: c.Name,
|
||||
Aaguid: c.AAGUID,
|
||||
SignCount: c.SignCount,
|
||||
Discoverable: c.Discoverable,
|
||||
Transports: c.Transports,
|
||||
CreatedAt: timestamppb.New(c.CreatedAt),
|
||||
}
|
||||
if c.LastUsedAt != nil {
|
||||
info.LastUsedAt = timestamppb.New(*c.LastUsedAt)
|
||||
}
|
||||
resp.Credentials = append(resp.Credentials, info)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
|
||||
// Requires: admin JWT in metadata.
|
||||
func (a *authServiceServer) RemoveWebAuthnCredential(ctx context.Context, req *mciasv1.RemoveWebAuthnCredentialRequest) (*mciasv1.RemoveWebAuthnCredentialResponse, error) {
|
||||
if err := a.s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.AccountId == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "account_id is required")
|
||||
}
|
||||
if req.CredentialId == 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "credential_id is required")
|
||||
}
|
||||
|
||||
acct, err := a.s.db.GetAccountByUUID(req.AccountId)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, "account not found")
|
||||
}
|
||||
|
||||
// DeleteWebAuthnCredentialAdmin bypasses ownership checks (admin operation).
|
||||
if err := a.s.db.DeleteWebAuthnCredentialAdmin(req.CredentialId); err != nil {
|
||||
a.s.logger.Error("delete webauthn credential", "error", err, "credential_id", req.CredentialId)
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
a.s.db.WriteAuditEvent(model.EventWebAuthnRemoved, nil, &acct.ID, peerIP(ctx), //nolint:errcheck
|
||||
fmt.Sprintf(`{"credential_id":%d}`, req.CredentialId))
|
||||
|
||||
return &mciasv1.RemoveWebAuthnCredentialResponse{}, nil
|
||||
}
|
||||
@@ -23,10 +23,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||
)
|
||||
|
||||
// contextKey is the unexported type for context keys in this package, preventing
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||
)
|
||||
|
||||
func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
@@ -361,8 +361,8 @@ func TestClientIP(t *testing.T) {
|
||||
remoteAddr string
|
||||
xForwardedFor string
|
||||
xRealIP string
|
||||
trustedProxy net.IP
|
||||
want string
|
||||
trustedProxy net.IP
|
||||
}{
|
||||
{
|
||||
name: "no proxy configured: uses RemoteAddr",
|
||||
@@ -377,11 +377,11 @@ func TestClientIP(t *testing.T) {
|
||||
want: "198.51.100.9",
|
||||
},
|
||||
{
|
||||
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
|
||||
remoteAddr: "10.0.0.1:8080",
|
||||
xRealIP: "203.0.113.42",
|
||||
trustedProxy: proxy,
|
||||
want: "203.0.113.42",
|
||||
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
|
||||
remoteAddr: "10.0.0.1:8080",
|
||||
xRealIP: "203.0.113.42",
|
||||
trustedProxy: proxy,
|
||||
want: "203.0.113.42",
|
||||
},
|
||||
{
|
||||
name: "request from trusted proxy with X-Forwarded-For: uses first entry",
|
||||
@@ -407,10 +407,10 @@ func TestClientIP(t *testing.T) {
|
||||
want: "203.0.113.55",
|
||||
},
|
||||
{
|
||||
name: "proxy request with no forwarding headers falls back to RemoteAddr host",
|
||||
remoteAddr: "10.0.0.1:8080",
|
||||
trustedProxy: proxy,
|
||||
want: "10.0.0.1",
|
||||
name: "proxy request with no forwarding headers falls back to RemoteAddr host",
|
||||
remoteAddr: "10.0.0.1:8080",
|
||||
trustedProxy: proxy,
|
||||
want: "10.0.0.1",
|
||||
},
|
||||
{
|
||||
// Security: attacker fakes X-Forwarded-For but connects directly.
|
||||
|
||||
@@ -213,6 +213,11 @@ const (
|
||||
|
||||
EventTokenDelegateGranted = "token_delegate_granted"
|
||||
EventTokenDelegateRevoked = "token_delegate_revoked"
|
||||
|
||||
EventWebAuthnEnrolled = "webauthn_enrolled"
|
||||
EventWebAuthnRemoved = "webauthn_removed"
|
||||
EventWebAuthnLoginOK = "webauthn_login_ok"
|
||||
EventWebAuthnLoginFail = "webauthn_login_fail"
|
||||
)
|
||||
|
||||
// ServiceAccountDelegate records that a specific account has been granted
|
||||
@@ -229,6 +234,26 @@ type ServiceAccountDelegate struct {
|
||||
GranteeID int64 `json:"-"`
|
||||
}
|
||||
|
||||
// WebAuthnCredential holds a stored WebAuthn/passkey credential.
|
||||
// Credential IDs and public keys are encrypted at rest with AES-256-GCM;
|
||||
// decrypted values must never be logged or included in API responses.
|
||||
type WebAuthnCredential struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
Name string `json:"name"`
|
||||
AAGUID string `json:"aaguid"`
|
||||
Transports string `json:"transports,omitempty"`
|
||||
CredentialIDEnc []byte `json:"-"`
|
||||
CredentialIDNonce []byte `json:"-"`
|
||||
PublicKeyEnc []byte `json:"-"`
|
||||
PublicKeyNonce []byte `json:"-"`
|
||||
ID int64 `json:"id"`
|
||||
AccountID int64 `json:"-"`
|
||||
SignCount uint32 `json:"sign_count"`
|
||||
Discoverable bool `json:"discoverable"`
|
||||
}
|
||||
|
||||
// PolicyRuleRecord is the database representation of a policy rule.
|
||||
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
||||
// The ID, Priority, and Description are stored as dedicated columns.
|
||||
|
||||
@@ -81,6 +81,16 @@ var defaultRules = []Rule{
|
||||
OwnerMatchesSubject: true,
|
||||
Effect: Allow,
|
||||
},
|
||||
{
|
||||
// Self-service WebAuthn enrollment: any authenticated human account may
|
||||
// register and manage their own passkeys/security keys. The handler
|
||||
// verifies the subject matches before writing. Mirrors TOTP rule -3.
|
||||
ID: -8,
|
||||
Description: "Self-service: any principal may enroll their own WebAuthn credentials",
|
||||
Priority: 0,
|
||||
Actions: []Action{ActionEnrollWebAuthn},
|
||||
Effect: Allow,
|
||||
},
|
||||
{
|
||||
// Public endpoints: token validation and login do not require
|
||||
// authentication. The middleware exempts them from RequireAuth entirely;
|
||||
|
||||
@@ -48,6 +48,9 @@ const (
|
||||
|
||||
ActionListRules Action = "policy:list"
|
||||
ActionManageRules Action = "policy:manage"
|
||||
|
||||
ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service
|
||||
ActionRemoveWebAuthn Action = "webauthn:remove" // admin
|
||||
)
|
||||
|
||||
// ResourceType identifies what kind of object a request targets.
|
||||
@@ -60,6 +63,7 @@ const (
|
||||
ResourceAuditLog ResourceType = "audit_log"
|
||||
ResourceTOTP ResourceType = "totp"
|
||||
ResourcePolicy ResourceType = "policy"
|
||||
ResourceWebAuthn ResourceType = "webauthn"
|
||||
)
|
||||
|
||||
// Effect is the outcome of policy evaluation.
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||
)
|
||||
|
||||
// ---- Tag endpoints ----
|
||||
|
||||
766
internal/server/handlers_webauthn.go
Normal file
766
internal/server/handlers_webauthn.go
Normal file
@@ -0,0 +1,766 @@
|
||||
// Package server: WebAuthn/passkey REST API handlers.
|
||||
//
|
||||
// Security design:
|
||||
// - Registration requires re-authentication (current password) to prevent a
|
||||
// stolen session token from enrolling attacker-controlled credentials.
|
||||
// - Challenge sessions are stored in a sync.Map with a 120-second TTL and are
|
||||
// single-use (deleted on consumption) to prevent replay attacks.
|
||||
// - All credential material (IDs, public keys) is encrypted at rest with
|
||||
// AES-256-GCM via the vault master key.
|
||||
// - Sign counter validation detects cloned authenticators.
|
||||
// - Login endpoints return generic errors to prevent credential enumeration.
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||
mciaswebauthn "git.wntrmute.dev/mc/mcias/internal/webauthn"
|
||||
)
|
||||
|
||||
const (
|
||||
webauthnCeremonyTTL = 120 * time.Second
|
||||
webauthnCleanupPeriod = 5 * time.Minute
|
||||
webauthnCeremonyNonce = 16 // 128 bits of entropy
|
||||
)
|
||||
|
||||
// webauthnCeremony holds a pending registration or login ceremony.
|
||||
type webauthnCeremony struct {
|
||||
expiresAt time.Time
|
||||
session *libwebauthn.SessionData
|
||||
accountID int64 // 0 for discoverable login
|
||||
}
|
||||
|
||||
// pendingWebAuthnCeremonies is the package-level ceremony store.
|
||||
// Stored on the Server struct would require adding fields; using a
|
||||
// package-level map is consistent with the TOTP/token pattern from the UI.
|
||||
var pendingWebAuthnCeremonies sync.Map //nolint:gochecknoglobals
|
||||
|
||||
func init() {
|
||||
go cleanupWebAuthnCeremonies()
|
||||
}
|
||||
|
||||
func cleanupWebAuthnCeremonies() {
|
||||
ticker := time.NewTicker(webauthnCleanupPeriod)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
pendingWebAuthnCeremonies.Range(func(key, value any) bool {
|
||||
c, ok := value.(*webauthnCeremony)
|
||||
if !ok || now.After(c.expiresAt) {
|
||||
pendingWebAuthnCeremonies.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func storeWebAuthnCeremony(session *libwebauthn.SessionData, accountID int64) (string, error) {
|
||||
raw, err := crypto.RandomBytes(webauthnCeremonyNonce)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err)
|
||||
}
|
||||
nonce := fmt.Sprintf("%x", raw)
|
||||
pendingWebAuthnCeremonies.Store(nonce, &webauthnCeremony{
|
||||
session: session,
|
||||
accountID: accountID,
|
||||
expiresAt: time.Now().Add(webauthnCeremonyTTL),
|
||||
})
|
||||
return nonce, nil
|
||||
}
|
||||
|
||||
func consumeWebAuthnCeremony(nonce string) (*webauthnCeremony, bool) {
|
||||
v, ok := pendingWebAuthnCeremonies.LoadAndDelete(nonce)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
c, ok2 := v.(*webauthnCeremony)
|
||||
if !ok2 || time.Now().After(c.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// ---- Registration ----
|
||||
|
||||
type webauthnRegisterBeginRequest struct {
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type webauthnRegisterBeginResponse struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Options json.RawMessage `json:"options"`
|
||||
}
|
||||
|
||||
// handleWebAuthnRegisterBegin starts a WebAuthn credential registration ceremony.
|
||||
//
|
||||
// Security (SEC-01): the current password is required to prevent a stolen
|
||||
// session from enrolling attacker-controlled credentials.
|
||||
func (s *Server) handleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.WebAuthnEnabled() {
|
||||
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
|
||||
return
|
||||
}
|
||||
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req webauthnRegisterBeginRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Password == "" {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "password is required", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check lockout before password verification.
|
||||
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
s.logger.Error("lockout check (WebAuthn register)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
||||
middleware.WriteError(w, http.StatusTooManyRequests, "account temporarily locked", "account_locked")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: verify current password with constant-time Argon2id.
|
||||
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
||||
if verifyErr != nil || !ok {
|
||||
_ = s.db.RecordLoginFailure(acct.ID)
|
||||
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "password is incorrect", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := s.vault.MasterKey()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||
return
|
||||
}
|
||||
|
||||
// Load existing credentials to exclude them from registration.
|
||||
dbCreds, err := s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
s.logger.Error("load webauthn credentials", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if err != nil {
|
||||
s.logger.Error("decrypt webauthn credentials", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
s.logger.Error("create webauthn instance", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
creation, session, err := wa.BeginRegistration(user,
|
||||
libwebauthn.WithExclusions(libwebauthn.Credentials(libCreds).CredentialDescriptors()),
|
||||
libwebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("begin webauthn registration", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
nonce, err := storeWebAuthnCeremony(session, acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
optionsJSON, err := json.Marshal(creation)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, webauthnRegisterBeginResponse{
|
||||
Options: optionsJSON,
|
||||
Nonce: nonce,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebAuthnRegisterFinish completes WebAuthn credential registration.
|
||||
func (s *Server) handleWebAuthnRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.WebAuthnEnabled() {
|
||||
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
|
||||
return
|
||||
}
|
||||
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "account not found", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Read the raw body so we can extract the nonce and also pass
|
||||
// the credential response to the library via a reconstructed request.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
|
||||
bodyBytes, err := readAllBody(r)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "invalid request body", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract nonce and name from the wrapper.
|
||||
var wrapper struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &wrapper); err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "invalid JSON", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
ceremony, ok := consumeWebAuthnCeremony(wrapper.Nonce)
|
||||
if !ok {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "ceremony expired or invalid", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
if ceremony.accountID != acct.ID {
|
||||
middleware.WriteError(w, http.StatusForbidden, "ceremony mismatch", "forbidden")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := s.vault.MasterKey()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||
return
|
||||
}
|
||||
|
||||
dbCreds, err := s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
|
||||
// Build a fake http.Request from the credential JSON for the library.
|
||||
fakeReq, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
fakeReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
cred, err := wa.FinishRegistration(user, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
s.logger.Error("finish webauthn registration", "error", err)
|
||||
middleware.WriteError(w, http.StatusBadRequest, "registration failed", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine if the credential is discoverable based on the flags.
|
||||
discoverable := cred.Flags.UserVerified && cred.Flags.BackupEligible
|
||||
|
||||
name := wrapper.Name
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
|
||||
// Encrypt and store the credential.
|
||||
modelCred, err := mciaswebauthn.EncryptCredential(masterKey, cred, name, discoverable)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
modelCred.AccountID = acct.ID
|
||||
|
||||
credID, err := s.db.CreateWebAuthnCredential(modelCred)
|
||||
if err != nil {
|
||||
s.logger.Error("store webauthn credential", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID,
|
||||
audit.JSON("credential_id", fmt.Sprintf("%d", credID), "name", name))
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"id": credID,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Login ----
|
||||
|
||||
type webauthnLoginBeginRequest struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
type webauthnLoginBeginResponse struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Options json.RawMessage `json:"options"`
|
||||
}
|
||||
|
||||
// handleWebAuthnLoginBegin starts a WebAuthn login ceremony.
|
||||
// If username is provided, loads that account's credentials (non-discoverable flow).
|
||||
// If empty, starts a discoverable login.
|
||||
func (s *Server) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.WebAuthnEnabled() {
|
||||
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
|
||||
return
|
||||
}
|
||||
|
||||
var req webauthnLoginBeginRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
s.logger.Error("create webauthn instance", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
assertion *protocol.CredentialAssertion
|
||||
session *libwebauthn.SessionData
|
||||
accountID int64
|
||||
)
|
||||
|
||||
if req.Username != "" {
|
||||
// Non-discoverable flow: load account credentials.
|
||||
acct, lookupErr := s.db.GetAccountByUsername(req.Username)
|
||||
if lookupErr != nil || acct.Status != model.AccountStatusActive {
|
||||
// Security: return a valid-looking response even for unknown users
|
||||
// to prevent username enumeration. Use discoverable login as a dummy.
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Check lockout.
|
||||
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
s.logger.Error("lockout check (WebAuthn login)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
// Return discoverable login as dummy to avoid enumeration.
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
masterKey, mkErr := s.vault.MasterKey()
|
||||
if mkErr != nil {
|
||||
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||
return
|
||||
}
|
||||
dbCreds, dbErr := s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if dbErr != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
if len(dbCreds) == 0 {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "no WebAuthn credentials registered", "no_credentials")
|
||||
return
|
||||
}
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if decErr != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
assertion, session, err = wa.BeginLogin(user)
|
||||
if err != nil {
|
||||
s.logger.Error("begin webauthn login", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
accountID = acct.ID
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Discoverable login (passkey).
|
||||
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
s.logger.Error("begin discoverable webauthn login", "error", err)
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
nonce, err := storeWebAuthnCeremony(session, accountID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
optionsJSON, err := json.Marshal(assertion)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, webauthnLoginBeginResponse{
|
||||
Options: optionsJSON,
|
||||
Nonce: nonce,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebAuthnLoginFinish completes a WebAuthn login ceremony and issues a JWT.
|
||||
func (s *Server) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.WebAuthnEnabled() {
|
||||
middleware.WriteError(w, http.StatusNotFound, "WebAuthn not configured", "not_found")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes)
|
||||
bodyBytes, err := readAllBody(r)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "invalid request body", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &wrapper); err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "invalid JSON", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
ceremony, ok := consumeWebAuthnCeremony(wrapper.Nonce)
|
||||
if !ok {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := mciaswebauthn.NewWebAuthn(&s.cfg.WebAuthn)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
masterKey, err := s.vault.MasterKey()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||
return
|
||||
}
|
||||
|
||||
fakeReq, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
fakeReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var (
|
||||
acct *model.Account
|
||||
cred *libwebauthn.Credential
|
||||
dbCreds []*model.WebAuthnCredential
|
||||
)
|
||||
|
||||
if ceremony.accountID != 0 {
|
||||
// Non-discoverable: we know the account.
|
||||
acct, err = s.db.GetAccountByID(ceremony.accountID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
dbCreds, err = s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||
if decErr != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||
cred, err = wa.FinishLogin(user, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"assertion_failed"}`)
|
||||
_ = s.db.RecordLoginFailure(acct.ID)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Discoverable login: the library resolves the user from the credential.
|
||||
handler := func(rawID, userHandle []byte) (libwebauthn.User, error) {
|
||||
// userHandle is the WebAuthnID we set (account UUID as bytes).
|
||||
acctUUID := string(userHandle)
|
||||
foundAcct, lookupErr := s.db.GetAccountByUUID(acctUUID)
|
||||
if lookupErr != nil {
|
||||
return nil, fmt.Errorf("account not found")
|
||||
}
|
||||
if foundAcct.Status != model.AccountStatusActive {
|
||||
return nil, fmt.Errorf("account inactive")
|
||||
}
|
||||
acct = foundAcct
|
||||
|
||||
foundDBCreds, credErr := s.db.GetWebAuthnCredentials(foundAcct.ID)
|
||||
if credErr != nil {
|
||||
return nil, fmt.Errorf("load credentials: %w", credErr)
|
||||
}
|
||||
dbCreds = foundDBCreds
|
||||
|
||||
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, foundDBCreds)
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("decrypt credentials: %w", decErr)
|
||||
}
|
||||
return mciaswebauthn.NewAccountUser(userHandle, foundAcct.Username, libCreds), nil
|
||||
}
|
||||
|
||||
cred, err = wa.FinishDiscoverableLogin(handler, *ceremony.session, fakeReq)
|
||||
if err != nil {
|
||||
s.writeAudit(r, model.EventWebAuthnLoginFail, nil, nil, `{"reason":"discoverable_assertion_failed"}`)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if acct == nil {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check account status and lockout.
|
||||
if acct.Status != model.AccountStatusActive {
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
locked, lockErr := s.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
s.logger.Error("lockout check (WebAuthn login finish)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Security: validate sign counter to detect cloned authenticators.
|
||||
// Find the matching DB credential to update.
|
||||
var matchedDBCred *model.WebAuthnCredential
|
||||
for _, dc := range dbCreds {
|
||||
decrypted, decErr := mciaswebauthn.DecryptCredential(masterKey, dc)
|
||||
if decErr != nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Equal(decrypted.ID, cred.ID) {
|
||||
matchedDBCred = dc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchedDBCred != nil {
|
||||
// Security: reject sign counter rollback (cloned authenticator detection).
|
||||
// If both are 0, the authenticator doesn't support counters — allow it.
|
||||
if cred.Authenticator.SignCount > 0 || matchedDBCred.SignCount > 0 {
|
||||
if cred.Authenticator.SignCount <= matchedDBCred.SignCount {
|
||||
s.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil,
|
||||
audit.JSON("reason", "counter_rollback",
|
||||
"expected_gt", fmt.Sprintf("%d", matchedDBCred.SignCount),
|
||||
"got", fmt.Sprintf("%d", cred.Authenticator.SignCount)))
|
||||
_ = s.db.RecordLoginFailure(acct.ID)
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update sign count and last used.
|
||||
_ = s.db.UpdateWebAuthnSignCount(matchedDBCred.ID, cred.Authenticator.SignCount)
|
||||
_ = s.db.UpdateWebAuthnLastUsed(matchedDBCred.ID)
|
||||
}
|
||||
|
||||
// Login succeeded: clear lockout counter.
|
||||
_ = s.db.ClearLoginFailures(acct.ID)
|
||||
|
||||
// Load roles for policy check and expiry decision.
|
||||
roles, err := s.db.GetRoles(acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
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()
|
||||
for _, role := range roles {
|
||||
if role == "admin" {
|
||||
expiry = s.cfg.AdminExpiry()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
privKey, err := s.vault.PrivKey()
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
|
||||
return
|
||||
}
|
||||
tokenStr, tokenClaims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.TrackToken(tokenClaims.JTI, acct.ID, tokenClaims.IssuedAt, tokenClaims.ExpiresAt); err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "")
|
||||
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, audit.JSON("jti", tokenClaims.JTI, "via", "webauthn"))
|
||||
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
Token: tokenStr,
|
||||
ExpiresAt: tokenClaims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Credential management ----
|
||||
|
||||
type webauthnCredentialView struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
Name string `json:"name"`
|
||||
AAGUID string `json:"aaguid"`
|
||||
Transports string `json:"transports,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
SignCount uint32 `json:"sign_count"`
|
||||
Discoverable bool `json:"discoverable"`
|
||||
}
|
||||
|
||||
// handleListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
|
||||
func (s *Server) handleListWebAuthnCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
acct, ok := s.loadAccount(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
creds, err := s.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
views := make([]webauthnCredentialView, 0, len(creds))
|
||||
for _, c := range creds {
|
||||
v := webauthnCredentialView{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
AAGUID: c.AAGUID,
|
||||
SignCount: c.SignCount,
|
||||
Discoverable: c.Discoverable,
|
||||
Transports: c.Transports,
|
||||
CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
if c.LastUsedAt != nil {
|
||||
v.LastUsedAt = c.LastUsedAt.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, views)
|
||||
}
|
||||
|
||||
// handleDeleteWebAuthnCredential removes a specific WebAuthn credential.
|
||||
func (s *Server) handleDeleteWebAuthnCredential(w http.ResponseWriter, r *http.Request) {
|
||||
acct, ok := s.loadAccount(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
credIDStr := r.PathValue("credentialId")
|
||||
credID, err := strconv.ParseInt(credIDStr, 10, 64)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "invalid credential ID", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.DeleteWebAuthnCredentialAdmin(credID); err != nil {
|
||||
middleware.WriteError(w, http.StatusNotFound, "credential not found", "not_found")
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventWebAuthnRemoved, nil, &acct.ID,
|
||||
audit.JSON("credential_id", credIDStr))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// readAllBody reads the entire request body and returns it as a byte slice.
|
||||
func readAllBody(r *http.Request) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
_, err := buf.ReadFrom(r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -21,19 +21,19 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
"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/ui"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
"git.wntrmute.dev/kyle/mcias/web"
|
||||
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||
"git.wntrmute.dev/mc/mcias/internal/ui"
|
||||
"git.wntrmute.dev/mc/mcias/internal/validate"
|
||||
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||
"git.wntrmute.dev/mc/mcias/web"
|
||||
)
|
||||
|
||||
// Server holds the dependencies injected into all handlers.
|
||||
@@ -232,6 +232,14 @@ func (s *Server) Handler() http.Handler {
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
|
||||
}
|
||||
swaggerJS, err := fs.ReadFile(staticFS, "swagger-ui-bundle.js")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("server: read swagger-ui-bundle.js: %v", err))
|
||||
}
|
||||
swaggerCSS, err := fs.ReadFile(staticFS, "swagger-ui.css")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("server: read swagger-ui.css: %v", err))
|
||||
}
|
||||
// Security (DEF-09): apply defensive HTTP headers to the docs handlers.
|
||||
// The Swagger UI page at /docs loads JavaScript from the same origin
|
||||
// and renders untrusted content (API descriptions), so it benefits from
|
||||
@@ -246,6 +254,16 @@ func (s *Server) Handler() http.Handler {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(specYAML)
|
||||
})))
|
||||
mux.Handle("GET /static/swagger-ui-bundle.js", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(swaggerJS)
|
||||
})))
|
||||
mux.Handle("GET /static/swagger-ui.css", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(swaggerCSS)
|
||||
})))
|
||||
|
||||
// Vault endpoints (exempt from sealed middleware and auth).
|
||||
unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
|
||||
@@ -290,6 +308,13 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
|
||||
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm)))
|
||||
|
||||
// WebAuthn registration endpoints (require valid token; self-service).
|
||||
mux.Handle("POST /v1/auth/webauthn/register/begin", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterBegin)))
|
||||
mux.Handle("POST /v1/auth/webauthn/register/finish", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterFinish)))
|
||||
// WebAuthn login endpoints (public, rate-limited).
|
||||
mux.Handle("POST /v1/auth/webauthn/login/begin", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginBegin)))
|
||||
mux.Handle("POST /v1/auth/webauthn/login/finish", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginFinish)))
|
||||
|
||||
// Policy-gated endpoints (formerly admin-only; now controlled by the engine).
|
||||
mux.Handle("DELETE /v1/auth/totp",
|
||||
requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove)))
|
||||
@@ -320,6 +345,11 @@ func (s *Server) Handler() http.Handler {
|
||||
requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/pgcreds",
|
||||
requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds)))
|
||||
// WebAuthn credential management (policy-gated).
|
||||
mux.Handle("GET /v1/accounts/{id}/webauthn",
|
||||
requirePolicy(policy.ActionReadAccount, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleListWebAuthnCredentials)))
|
||||
mux.Handle("DELETE /v1/accounts/{id}/webauthn/{credentialId}",
|
||||
requirePolicy(policy.ActionRemoveWebAuthn, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleDeleteWebAuthnCredential)))
|
||||
mux.Handle("GET /v1/audit",
|
||||
requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit)))
|
||||
mux.Handle("GET /v1/accounts/{id}/tags",
|
||||
@@ -406,6 +436,12 @@ type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
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.
|
||||
@@ -516,13 +552,42 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// Login succeeded: clear any outstanding failure counter.
|
||||
_ = s.db.ClearLoginFailures(acct.ID)
|
||||
|
||||
// Determine expiry.
|
||||
expiry := s.cfg.DefaultExpiry()
|
||||
// Load roles for expiry decision and policy check.
|
||||
roles, err := s.db.GetRoles(acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
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 {
|
||||
if r == "admin" {
|
||||
expiry = s.cfg.AdminExpiry()
|
||||
@@ -639,11 +704,12 @@ type validateRequest struct {
|
||||
}
|
||||
|
||||
type validateResponse struct {
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
AccountType string `json:"account_type,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -688,6 +754,7 @@ func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if acct, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
resp.Username = acct.Username
|
||||
resp.AccountType = string(acct.AccountType)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"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/vault"
|
||||
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||
)
|
||||
|
||||
// generateTOTPCode computes a valid RFC 6238 TOTP code for the current time
|
||||
|
||||
@@ -4,10 +4,10 @@ package server
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||
"git.wntrmute.dev/mc/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||
)
|
||||
|
||||
// unsealRequest is the request body for POST /v1/vault/unseal.
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||
)
|
||||
|
||||
func TestHandleHealthSealed(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package ui
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||
)
|
||||
|
||||
// uiContextKey is the unexported type for UI context values, preventing
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||
)
|
||||
|
||||
// CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection.
|
||||
@@ -29,15 +29,9 @@ import (
|
||||
// on the next unseal. This is safe because sealed middleware prevents
|
||||
// reaching CSRF-protected routes.
|
||||
type CSRFManager struct {
|
||||
mu sync.Mutex
|
||||
key []byte
|
||||
vault *vault.Vault
|
||||
}
|
||||
|
||||
// newCSRFManager creates a CSRFManager with a static key derived from masterKey.
|
||||
// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey)
|
||||
func newCSRFManager(masterKey []byte) *CSRFManager {
|
||||
return &CSRFManager{key: deriveCSRFKey(masterKey)}
|
||||
key []byte
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/validate"
|
||||
)
|
||||
|
||||
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.
|
||||
@@ -197,6 +197,15 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Load WebAuthn credentials for the account detail page.
|
||||
var webAuthnCreds []*model.WebAuthnCredential
|
||||
if u.cfg.WebAuthnEnabled() {
|
||||
webAuthnCreds, err = u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err != nil {
|
||||
u.logger.Warn("load webauthn credentials", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
u.render(w, "account_detail", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
||||
Account: acct,
|
||||
@@ -211,6 +220,9 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
TokenDelegates: tokenDelegates,
|
||||
DelegatableAccounts: delegatableAccounts,
|
||||
CanIssueToken: true, // account_detail is admin-only, so admin can always issue
|
||||
WebAuthnCreds: webAuthnCreds,
|
||||
DeletePrefix: "/accounts/" + acct.UUID + "/webauthn",
|
||||
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
const auditPageSize = 50
|
||||
|
||||
@@ -3,17 +3,19 @@ package ui
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||
"git.wntrmute.dev/mc/mcias/internal/validate"
|
||||
)
|
||||
|
||||
// handleLoginPage renders the login form.
|
||||
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, "login", LoginData{})
|
||||
u.render(w, "login", LoginData{
|
||||
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleLoginPost processes username+password (step 1) or TOTP code (step 2).
|
||||
@@ -290,13 +292,33 @@ func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, target
|
||||
// handleProfilePage renders the profile page for the currently logged-in user.
|
||||
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "profile", ProfileData{
|
||||
claims := claimsFromContext(r.Context())
|
||||
|
||||
data := ProfileData{
|
||||
PageData: PageData{
|
||||
CSRFToken: csrfToken,
|
||||
ActorName: u.actorName(r),
|
||||
IsAdmin: isAdmin(r),
|
||||
},
|
||||
})
|
||||
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||
DeletePrefix: "/profile/webauthn",
|
||||
}
|
||||
|
||||
if claims != nil {
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err == nil {
|
||||
data.TOTPEnabled = acct.TOTPRequired
|
||||
// Load WebAuthn credentials for the profile page.
|
||||
if u.cfg.WebAuthnEnabled() {
|
||||
creds, err := u.db.GetWebAuthnCredentials(acct.ID)
|
||||
if err == nil {
|
||||
data.WebAuthnCreds = creds
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u.render(w, "profile", data)
|
||||
}
|
||||
|
||||
// handleSelfChangePassword allows an authenticated human user to change their
|
||||
|
||||
@@ -3,8 +3,8 @@ package ui
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
// handleDashboard renders the main dashboard page. Admin users see account
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||
)
|
||||
|
||||
// ---- Policies page ----
|
||||
@@ -129,46 +129,69 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
|
||||
priority = p
|
||||
}
|
||||
|
||||
effectStr := r.FormValue("effect")
|
||||
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
|
||||
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
|
||||
return
|
||||
}
|
||||
var ruleJSON []byte
|
||||
|
||||
body := policy.RuleBody{
|
||||
Effect: policy.Effect(effectStr),
|
||||
}
|
||||
|
||||
// Multi-value fields.
|
||||
if roles := r.Form["roles"]; len(roles) > 0 {
|
||||
body.Roles = roles
|
||||
}
|
||||
if types := r.Form["account_types"]; len(types) > 0 {
|
||||
body.AccountTypes = types
|
||||
}
|
||||
if actions := r.Form["actions"]; len(actions) > 0 {
|
||||
acts := make([]policy.Action, len(actions))
|
||||
for i, a := range actions {
|
||||
acts[i] = policy.Action(a)
|
||||
if rawJSON := strings.TrimSpace(r.FormValue("rule_json")); rawJSON != "" {
|
||||
// JSON mode: parse and re-marshal to normalise and validate the input.
|
||||
var body policy.RuleBody
|
||||
if err := json.Unmarshal([]byte(rawJSON), &body); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, fmt.Sprintf("invalid rule JSON: %v", err))
|
||||
return
|
||||
}
|
||||
if body.Effect != policy.Allow && body.Effect != policy.Deny {
|
||||
u.renderError(w, r, http.StatusBadRequest, "rule JSON must include effect 'allow' or 'deny'")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
ruleJSON, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Form mode: build RuleBody from individual fields.
|
||||
effectStr := r.FormValue("effect")
|
||||
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
|
||||
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
|
||||
return
|
||||
}
|
||||
body.Actions = acts
|
||||
}
|
||||
if resType := r.FormValue("resource_type"); resType != "" {
|
||||
body.ResourceType = policy.ResourceType(resType)
|
||||
}
|
||||
body.SubjectUUID = strings.TrimSpace(r.FormValue("subject_uuid"))
|
||||
body.OwnerMatchesSubject = r.FormValue("owner_matches_subject") == "1"
|
||||
if svcNames := r.FormValue("service_names"); svcNames != "" {
|
||||
body.ServiceNames = splitCommas(svcNames)
|
||||
}
|
||||
if tags := r.FormValue("required_tags"); tags != "" {
|
||||
body.RequiredTags = splitCommas(tags)
|
||||
}
|
||||
|
||||
ruleJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
body := policy.RuleBody{
|
||||
Effect: policy.Effect(effectStr),
|
||||
}
|
||||
|
||||
// Multi-value fields.
|
||||
if roles := r.Form["roles"]; len(roles) > 0 {
|
||||
body.Roles = roles
|
||||
}
|
||||
if types := r.Form["account_types"]; len(types) > 0 {
|
||||
body.AccountTypes = types
|
||||
}
|
||||
if actions := r.Form["actions"]; len(actions) > 0 {
|
||||
acts := make([]policy.Action, len(actions))
|
||||
for i, a := range actions {
|
||||
acts[i] = policy.Action(a)
|
||||
}
|
||||
body.Actions = acts
|
||||
}
|
||||
if resType := r.FormValue("resource_type"); resType != "" {
|
||||
body.ResourceType = policy.ResourceType(resType)
|
||||
}
|
||||
body.SubjectUUID = strings.TrimSpace(r.FormValue("subject_uuid"))
|
||||
body.OwnerMatchesSubject = r.FormValue("owner_matches_subject") == "1"
|
||||
if svcNames := r.FormValue("service_names"); svcNames != "" {
|
||||
body.ServiceNames = splitCommas(svcNames)
|
||||
}
|
||||
if tags := r.FormValue("required_tags"); tags != "" {
|
||||
body.RequiredTags = splitCommas(tags)
|
||||
}
|
||||
|
||||
var err error
|
||||
ruleJSON, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Parse optional time-scoped validity window from datetime-local inputs.
|
||||
|
||||
288
internal/ui/handlers_totp.go
Normal file
288
internal/ui/handlers_totp.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
|
||||
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
)
|
||||
|
||||
// handleTOTPEnrollStart processes the password re-auth step and generates
|
||||
// the TOTP secret + QR code for the user to scan.
|
||||
//
|
||||
// Security (SEC-01): the current password is required to prevent a stolen
|
||||
// session from enrolling attacker-controlled TOTP. Lockout is checked and
|
||||
// failures are recorded to prevent brute-force use as a password oracle.
|
||||
func (u *UIServer) handleTOTPEnrollStart(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "invalid form submission"})
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Already enrolled — show enabled status.
|
||||
if acct.TOTPRequired {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPEnabled: true})
|
||||
return
|
||||
}
|
||||
|
||||
password := r.FormValue("password")
|
||||
if password == "" {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "password is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Security: check lockout before verifying password.
|
||||
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
u.logger.Error("lockout check (UI TOTP enroll)", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"locked"}`)
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "account temporarily locked, please try again later"})
|
||||
return
|
||||
}
|
||||
|
||||
// Security: verify current password with constant-time Argon2id path.
|
||||
ok, verifyErr := auth.VerifyPassword(password, acct.PasswordHash)
|
||||
if verifyErr != nil || !ok {
|
||||
_ = u.db.RecordLoginFailure(acct.ID)
|
||||
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, &acct.ID, `{"result":"wrong_password"}`)
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "password is incorrect"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate TOTP secret.
|
||||
rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
|
||||
if err != nil {
|
||||
u.logger.Error("generate TOTP secret", "error", err)
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt and store as pending (totp_required stays 0 until confirmed).
|
||||
masterKey, err := u.vault.MasterKey()
|
||||
if err != nil {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
secretEnc, secretNonce, err := crypto.SealAESGCM(masterKey, rawSecret)
|
||||
if err != nil {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Security: use StorePendingTOTP (not SetTOTP) so that totp_required
|
||||
// remains 0 until the user proves possession via ConfirmTOTP.
|
||||
if err := u.db.StorePendingTOTP(acct.ID, secretEnc, secretNonce); err != nil {
|
||||
u.logger.Error("store pending TOTP", "error", err)
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
otpURI := fmt.Sprintf("otpauth://totp/MCIAS:%s?secret=%s&issuer=MCIAS", acct.Username, b32Secret)
|
||||
|
||||
// Generate QR code PNG.
|
||||
png, err := qrcode.Encode(otpURI, qrcode.Medium, 200)
|
||||
if err != nil {
|
||||
u.logger.Error("generate QR code", "error", err)
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
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.
|
||||
nonce, err := u.issueTOTPEnrollNonce(acct.ID)
|
||||
if err != nil {
|
||||
u.logger.Error("issue TOTP enroll nonce", "error", err)
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "totp_enroll_qr", ProfileData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
TOTPSecret: b32Secret,
|
||||
TOTPQR: qrDataURI,
|
||||
TOTPEnrollNonce: nonce,
|
||||
})
|
||||
}
|
||||
|
||||
// handleTOTPConfirm validates the TOTP code and activates enrollment.
|
||||
//
|
||||
// Security (CRIT-01): the counter is recorded to prevent replay of the same
|
||||
// code within its validity window.
|
||||
func (u *UIServer) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "invalid form submission"})
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
nonce := r.FormValue("totp_enroll_nonce")
|
||||
totpCode := r.FormValue("totp_code")
|
||||
|
||||
// Security: consume the nonce (single-use); reject if unknown or expired.
|
||||
accountID, ok := u.consumeTOTPEnrollNonce(nonce)
|
||||
if !ok {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "session expired, please start enrollment again"})
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByID(accountID)
|
||||
if err != nil {
|
||||
u.logger.Error("get account for TOTP confirm", "error", err, "account_id", accountID)
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Security: verify nonce accountID matches session claims.
|
||||
if acct.UUID != claims.Subject {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "session mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
if acct.TOTPSecretEnc == nil {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "enrollment not started"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt and validate TOTP code.
|
||||
masterKey, err := u.vault.MasterKey()
|
||||
if err != nil {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||
if err != nil {
|
||||
u.logger.Error("decrypt TOTP secret for confirm", "error", err, "account_id", acct.ID)
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
valid, totpCounter, err := auth.ValidateTOTP(secret, totpCode)
|
||||
if err != nil || !valid {
|
||||
// Re-issue a fresh nonce so the user can retry without restarting.
|
||||
u.reissueTOTPEnrollQR(w, r, acct, secret, "invalid TOTP code")
|
||||
return
|
||||
}
|
||||
|
||||
// Security (CRIT-01): reject replay of a code already used.
|
||||
if err := u.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
|
||||
u.reissueTOTPEnrollQR(w, r, acct, secret, "invalid TOTP code")
|
||||
return
|
||||
}
|
||||
|
||||
// Activate TOTP (sets totp_required=1).
|
||||
if err := u.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
|
||||
u.logger.Error("set TOTP", "error", err, "account_id", acct.ID)
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventTOTPEnrolled, &acct.ID, nil, "")
|
||||
|
||||
u.renderTOTPSection(w, r, ProfileData{
|
||||
TOTPEnabled: true,
|
||||
TOTPSuccess: "Two-factor authentication enabled successfully.",
|
||||
})
|
||||
}
|
||||
|
||||
// reissueTOTPEnrollQR re-renders the QR code page with a fresh nonce after
|
||||
// a failed code confirmation, so the user can retry without restarting.
|
||||
func (u *UIServer) reissueTOTPEnrollQR(w http.ResponseWriter, r *http.Request, acct *model.Account, secret []byte, errMsg string) {
|
||||
b32Secret := base32.StdEncoding.EncodeToString(secret)
|
||||
otpURI := fmt.Sprintf("otpauth://totp/MCIAS:%s?secret=%s&issuer=MCIAS", acct.Username, b32Secret)
|
||||
|
||||
png, err := qrcode.Encode(otpURI, qrcode.Medium, 200)
|
||||
if err != nil {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
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)
|
||||
if nonceErr != nil {
|
||||
u.renderTOTPSection(w, r, ProfileData{TOTPError: "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "totp_enroll_qr", ProfileData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
TOTPSecret: b32Secret,
|
||||
TOTPQR: qrDataURI,
|
||||
TOTPEnrollNonce: newNonce,
|
||||
TOTPError: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminTOTPRemove removes TOTP from an account (admin only).
|
||||
func (u *UIServer) handleAdminTOTPRemove(w http.ResponseWriter, r *http.Request) {
|
||||
accountUUID := r.PathValue("id")
|
||||
if accountUUID == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "missing account ID")
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := u.db.GetAccountByUUID(accountUUID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.ClearTOTP(acct.ID); err != nil {
|
||||
u.logger.Error("clear TOTP (admin)", "error", err, "account_id", acct.ID)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventTOTPRemoved, actorID, &acct.ID,
|
||||
audit.JSON("admin", "true"))
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = fmt.Fprint(w, `Disabled <span class="text-muted text-small">(removed)</span>`)
|
||||
}
|
||||
|
||||
// renderTOTPSection is a helper to render the totp_section fragment with
|
||||
// common page data fields populated.
|
||||
func (u *UIServer) renderTOTPSection(w http.ResponseWriter, r *http.Request, data ProfileData) {
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
data.CSRFToken = csrfToken
|
||||
data.ActorName = u.actorName(r)
|
||||
data.IsAdmin = isAdmin(r)
|
||||
u.render(w, "totp_section", data)
|
||||
}
|
||||
@@ -4,10 +4,10 @@ package ui
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||
"git.wntrmute.dev/mc/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||
)
|
||||
|
||||
// UnsealData is the view model for the unseal page.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user