Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
go.work.sum
|
go.work.sum
|
||||||
dist/mcias_*.tar.gz
|
# dist/ is purely build output (tarballs); never commit it
|
||||||
|
dist/
|
||||||
man/man1/*.gz
|
man/man1/*.gz
|
||||||
|
|
||||||
# Client library build artifacts
|
# Client library build artifacts
|
||||||
|
|||||||
@@ -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
|
### Components
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ MCIAS Server (mciassrv) │
|
│ MCIAS Server (mciassrv) │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
|
||||||
│ │ Auth │ │ Token │ │ Account / Role │ │
|
│ │ Auth │ │ Token │ │ Account / Role │ │
|
||||||
│ │ Handler │ │ Manager │ │ Manager │ │
|
│ │ Handler │ │ Manager │ │ Manager │ │
|
||||||
│ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘ │
|
│ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘ │
|
||||||
│ └─────────────┴─────────────────┘ │
|
│ └─────────────┴─────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌─────────▼──────────┐ │
|
│ ┌─────────▼──────────┐ │
|
||||||
│ │ SQLite Database │ │
|
│ │ SQLite Database │ │
|
||||||
│ └────────────────────┘ │
|
│ └────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
||||||
@@ -32,10 +32,10 @@ parties that delegate authentication decisions to it.
|
|||||||
│ │ (net/http) │ │ (google.golang.org/ │ │
|
│ │ (net/http) │ │ (google.golang.org/ │ │
|
||||||
│ │ :8443 │ │ grpc) :9443 │ │
|
│ │ :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 │
|
│ Personal │ │ mciasctl │ │ mciasgrpcctl │ │ mciasdb │
|
||||||
│ Apps │ │ (admin │ │ (gRPC admin │ │ (DB tool) │
|
│ 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:
|
**Human accounts** — interactive users. Can authenticate via:
|
||||||
- Username + password (Argon2id hash stored in DB)
|
- Username + password (Argon2id hash stored in DB)
|
||||||
- Optional TOTP (RFC 6238); if enrolled, required on every login
|
- 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:
|
**System accounts** — non-interactive service identities. Have:
|
||||||
- A single active bearer token at a time (rotating the token revokes the old one)
|
- 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 |
|
| POST | `/v1/token/issue` | admin JWT | Issue service account token |
|
||||||
| DELETE | `/v1/token/{jti}` | admin JWT | Revoke token by JTI |
|
| 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 |
|
| 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 |
|
| GET | `/v1/accounts/{id}` | admin JWT | Get account details |
|
||||||
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
|
| PATCH | `/v1/accounts/{id}` | admin JWT | Update account (status, roles, etc.) |
|
||||||
| DELETE | `/v1/accounts/{id}` | admin JWT | Soft-delete account |
|
| 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
|
### 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 |
|
| POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code |
|
||||||
| DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) |
|
| 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
|
### Postgres Credential Endpoints
|
||||||
|
|
||||||
| Method | Path | Auth required | Description |
|
| 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 |
|
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials |
|
||||||
|
|
||||||
### Tag Endpoints (admin only)
|
### Tag Endpoints (admin only)
|
||||||
@@ -479,6 +511,7 @@ cookie pattern (`mcias_csrf`).
|
|||||||
| `/policies` | Policy rules management — create, enable/disable, delete |
|
| `/policies` | Policy rules management — create, enable/disable, delete |
|
||||||
| `/audit` | Audit log viewer |
|
| `/audit` | Audit log viewer |
|
||||||
| `/profile` | User profile — self-service password change (any authenticated user) |
|
| `/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
|
**HTMX fragments:** Mutating operations (role updates, tag edits, credential
|
||||||
saves, policy toggles, access grants) use HTMX partial-page updates for a
|
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)
|
not_before TEXT DEFAULT NULL, -- optional: earliest activation time (RFC3339)
|
||||||
expires_at TEXT DEFAULT NULL -- optional: expiry 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
|
### Schema Notes
|
||||||
@@ -665,9 +735,10 @@ CREATE TABLE policy_rules (
|
|||||||
- Passwords are stored as PHC-format Argon2id strings (e.g.,
|
- Passwords are stored as PHC-format Argon2id strings (e.g.,
|
||||||
`$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm
|
`$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm
|
||||||
parameters. Future parameter upgrades are transparent.
|
parameters. Future parameter upgrades are transparent.
|
||||||
- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a
|
- TOTP secrets, Postgres passwords, and WebAuthn credential IDs/public keys are
|
||||||
master key held only in server memory (derived at startup from a passphrase
|
encrypted with AES-256-GCM using a master key held only in server memory
|
||||||
or keyfile). The nonce is stored adjacent to the ciphertext.
|
(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
|
- 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.
|
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
|
- The signing key encryption is layered: the Ed25519 private key is wrapped
|
||||||
@@ -735,30 +806,45 @@ mcias/
|
|||||||
│ │ └── main.go
|
│ │ └── main.go
|
||||||
│ ├── mciasctl/ # REST admin CLI
|
│ ├── mciasctl/ # REST admin CLI
|
||||||
│ │ └── main.go
|
│ │ └── main.go
|
||||||
│ ├── mciasdb/ # direct SQLite maintenance tool (Phase 6)
|
│ ├── mciasdb/ # direct SQLite maintenance tool
|
||||||
│ │ └── main.go
|
│ │ └── main.go
|
||||||
│ └── mciasgrpcctl/ # gRPC admin CLI companion (Phase 7)
|
│ └── mciasgrpcctl/ # gRPC admin CLI companion
|
||||||
│ └── main.go
|
│ └── main.go
|
||||||
├── internal/
|
├── internal/
|
||||||
|
│ ├── audit/ # audit log event detail marshaling
|
||||||
│ ├── auth/ # login flow, TOTP verification, account lockout
|
│ ├── auth/ # login flow, TOTP verification, account lockout
|
||||||
│ ├── config/ # config file parsing and validation
|
│ ├── config/ # config file parsing and validation
|
||||||
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation
|
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation
|
||||||
│ ├── db/ # SQLite access layer (schema, migrations, queries)
|
│ ├── 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)
|
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy)
|
||||||
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
|
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
|
||||||
│ ├── policy/ # in-process authorization policy engine (§20)
|
│ ├── policy/ # in-process authorization policy engine (§20)
|
||||||
│ ├── server/ # HTTP handlers, router setup
|
│ ├── server/ # HTTP handlers, router setup
|
||||||
│ ├── token/ # JWT issuance, validation, revocation
|
│ ├── token/ # JWT issuance, validation, revocation
|
||||||
│ ├── ui/ # web UI context, CSRF, session, template handlers
|
│ ├── 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/
|
├── web/
|
||||||
│ ├── static/ # CSS and static assets
|
│ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build)
|
||||||
│ └── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
│ ├── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
||||||
|
│ └── embed.go # fs.FS embedding of static files and templates
|
||||||
├── proto/
|
├── proto/
|
||||||
│ └── mcias/v1/ # Protobuf service definitions (Phase 7)
|
│ └── mcias/v1/ # Protobuf service definitions
|
||||||
├── gen/
|
├── 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
|
└── 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_updated` | Policy rule updated (priority, enabled, description) |
|
||||||
| `policy_rule_deleted` | Policy rule deleted |
|
| `policy_rule_deleted` | Policy rule deleted |
|
||||||
| `policy_deny` | Policy engine denied a request (logged for every explicit deny) |
|
| `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_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` |
|
| `vault_sealed` | Vault sealed via REST API; details include actor ID, `source`, and `ip` |
|
||||||
|
|
||||||
@@ -970,7 +1058,8 @@ proto/
|
|||||||
└── v1/
|
└── v1/
|
||||||
├── auth.proto # Login, Logout, Renew, TOTP enroll/confirm/remove
|
├── auth.proto # Login, Logout, Renew, TOTP enroll/confirm/remove
|
||||||
├── token.proto # Validate, Issue, Revoke
|
├── 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
|
├── admin.proto # Health, public-key retrieval
|
||||||
└── common.proto # Shared message types (Error, Timestamp wrappers)
|
└── 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` |
|
| `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` |
|
||||||
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
|
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
|
||||||
| `CredentialService` | `GetPGCreds`, `SetPGCreds` |
|
| `CredentialService` | `GetPGCreds`, `SetPGCreds` |
|
||||||
|
| `PolicyService` | `ListPolicyRules`, `CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`, `DeletePolicyRule` |
|
||||||
| `AdminService` | `Health`, `GetPublicKey` |
|
| `AdminService` | `Health`, `GetPublicKey` |
|
||||||
|
|
||||||
All request/response messages follow the same credential-exclusion rules as
|
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) |
|
| `generate` | `go generate ./...` (re-generates proto stubs) |
|
||||||
| `man` | Build man pages; compress to `.gz` in `man/` |
|
| `man` | Build man pages; compress to `.gz` in `man/` |
|
||||||
| `install` | Run `dist/install.sh` |
|
| `install` | Run `dist/install.sh` |
|
||||||
| `docker` | `docker build -t mcias:$(VERSION) .` |
|
| `docker` | `docker build -t mcias:$(VERSION) -t mcias:latest .` |
|
||||||
| `clean` | Remove `bin/` and compressed man pages |
|
| `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 |
|
| `dist` | Cross-compile release tarballs for linux/amd64 and linux/arm64 |
|
||||||
|
|
||||||
### Upgrade Path
|
### Upgrade Path
|
||||||
@@ -1286,7 +1377,7 @@ Error types exposed by every library:
|
|||||||
|
|
||||||
#### Go (`clients/go/`)
|
#### Go (`clients/go/`)
|
||||||
|
|
||||||
- Module: `git.wntrmute.dev/kyle/mcias/clients/go`
|
- Module: `git.wntrmute.dev/mc/mcias/clients/go`
|
||||||
- Package: `mciasgoclient`
|
- Package: `mciasgoclient`
|
||||||
- HTTP: `net/http` with custom `*tls.Config` for CA cert
|
- HTTP: `net/http` with custom `*tls.Config` for CA cert
|
||||||
- Token state: guarded by `sync.RWMutex`
|
- Token state: guarded by `sync.RWMutex`
|
||||||
@@ -1373,6 +1464,8 @@ needed:
|
|||||||
|
|
||||||
- A human account should be able to access credentials for one specific service
|
- A human account should be able to access credentials for one specific service
|
||||||
without being a full admin.
|
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
|
- A system account (`deploy-agent`) should only operate on hosts tagged
|
||||||
`env:staging`, not `env:production`.
|
`env:staging`, not `env:production`.
|
||||||
- A "secrets reader" role should read pgcreds for any service but change nothing.
|
- 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.
|
// Rule is a single policy statement. All populated fields are ANDed.
|
||||||
// A zero/empty field is a wildcard (matches anything).
|
// A zero/empty field is a wildcard (matches anything).
|
||||||
type Rule struct {
|
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
|
Description string
|
||||||
|
|
||||||
// Principal match conditions
|
// Principal match conditions
|
||||||
@@ -1681,3 +1774,157 @@ introduced.
|
|||||||
| `policy_rule_deleted` | Rule deleted |
|
| `policy_rule_deleted` | Rule deleted |
|
||||||
| `tag_added` | Tag added to an account |
|
| `tag_added` | Tag added to an account |
|
||||||
| `tag_removed` | Tag removed from 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
|
# Dockerfile — MCIAS multi-stage container image
|
||||||
#
|
#
|
||||||
# Stage 1 (builder): Compiles all four MCIAS binaries.
|
# Stage 1 (builder): Compiles all four MCIAS binaries.
|
||||||
# Stage 2 (runtime): Minimal Debian image containing only the binaries.
|
# Stage 2 (runtime): Minimal Alpine image containing only the binaries.
|
||||||
|
#
|
||||||
|
# modernc.org/sqlite is a pure-Go, CGo-free SQLite port. CGO_ENABLED=0
|
||||||
|
# produces fully static binaries with no C library dependencies, which
|
||||||
|
# deploy cleanly onto a minimal Alpine runtime image.
|
||||||
#
|
#
|
||||||
# The final image:
|
# The final image:
|
||||||
# - Runs as non-root uid 10001 (mcias)
|
# - Runs as non-root uid 10001 (mcias)
|
||||||
# - Exposes port 8443 (REST/TLS) and 9443 (gRPC/TLS)
|
# - Exposes port 8443 (REST/TLS) and 9443 (gRPC/TLS)
|
||||||
# - Declares VOLUME /data for the SQLite database
|
# - Declares VOLUME /srv/mcias for config, TLS, and database
|
||||||
# - Does NOT contain the Go toolchain, source code, or build cache
|
# - Does NOT contain the Go toolchain, source code, or build cache
|
||||||
#
|
#
|
||||||
# Build:
|
# Build:
|
||||||
@@ -15,8 +19,7 @@
|
|||||||
# Run:
|
# Run:
|
||||||
# docker run -d \
|
# docker run -d \
|
||||||
# --name mcias \
|
# --name mcias \
|
||||||
# -v /path/to/config:/etc/mcias:ro \
|
# -v /srv/mcias:/srv/mcias \
|
||||||
# -v mcias-data:/data \
|
|
||||||
# -e MCIAS_MASTER_PASSPHRASE=your-passphrase \
|
# -e MCIAS_MASTER_PASSPHRASE=your-passphrase \
|
||||||
# -p 8443:8443 \
|
# -p 8443:8443 \
|
||||||
# -p 9443:9443 \
|
# -p 9443:9443 \
|
||||||
@@ -25,7 +28,7 @@
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Stage 1 — builder
|
# Stage 1 — builder
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
FROM golang:1.26-bookworm AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -36,35 +39,29 @@ RUN go mod download
|
|||||||
# Copy source.
|
# Copy source.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# CGO_ENABLED=1 is required by modernc.org/sqlite (pure-Go CGo-free SQLite).
|
# CGO_ENABLED=0: modernc.org/sqlite is pure Go; no C toolchain required.
|
||||||
# -trimpath removes local file system paths from the binary.
|
# -trimpath removes local file system paths from the binary.
|
||||||
# -ldflags="-s -w" strips the DWARF debug info and symbol table to reduce
|
# -ldflags="-s -w" strips the DWARF debug info and symbol table to reduce
|
||||||
# image size.
|
# image size.
|
||||||
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciassrv ./cmd/mciassrv && \
|
||||||
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasctl ./cmd/mciasctl && \
|
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasctl ./cmd/mciasctl && \
|
||||||
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasdb ./cmd/mciasdb && \
|
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasdb ./cmd/mciasdb && \
|
||||||
CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl
|
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mciasgrpcctl ./cmd/mciasgrpcctl
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Stage 2 — runtime
|
# Stage 2 — runtime
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
FROM debian:bookworm-slim
|
FROM alpine:3.21
|
||||||
|
|
||||||
# Install runtime dependencies.
|
|
||||||
# ca-certificates: required to validate external TLS certificates.
|
# ca-certificates: required to validate external TLS certificates.
|
||||||
# libc6: required by CGo-compiled binaries (sqlite).
|
RUN apk add --no-cache ca-certificates
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates \
|
|
||||||
libc6 && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Create a non-root user for the service.
|
# Create a non-root user for the service.
|
||||||
# uid/gid 10001 is chosen to be well above the range typically assigned to
|
# uid/gid 10001 is chosen to be well above the range typically assigned to
|
||||||
# system users (1–999) and human users (1000+), reducing the chance of
|
# system users (1–999) and human users (1000+), reducing the chance of
|
||||||
# collision with existing uids on the host when using host networking.
|
# collision with existing uids on the host when using host networking.
|
||||||
RUN groupadd --gid 10001 mcias && \
|
RUN addgroup -g 10001 mcias && \
|
||||||
useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin mcias
|
adduser -u 10001 -G mcias -H -s /sbin/nologin -D mcias
|
||||||
|
|
||||||
# Copy compiled binaries from the builder stage.
|
# Copy compiled binaries from the builder stage.
|
||||||
COPY --from=builder /out/mciassrv /usr/local/bin/mciassrv
|
COPY --from=builder /out/mciassrv /usr/local/bin/mciassrv
|
||||||
@@ -72,17 +69,15 @@ COPY --from=builder /out/mciasctl /usr/local/bin/mciasctl
|
|||||||
COPY --from=builder /out/mciasdb /usr/local/bin/mciasdb
|
COPY --from=builder /out/mciasdb /usr/local/bin/mciasdb
|
||||||
COPY --from=builder /out/mciasgrpcctl /usr/local/bin/mciasgrpcctl
|
COPY --from=builder /out/mciasgrpcctl /usr/local/bin/mciasgrpcctl
|
||||||
|
|
||||||
# Create the config and data directories.
|
# Create the data directory.
|
||||||
# /etc/mcias is mounted read-only by the operator with the config file,
|
# /srv/mcias is mounted from the host with config, TLS certs, and database.
|
||||||
# TLS cert, and TLS key.
|
RUN mkdir -p /srv/mcias/certs /srv/mcias/backups && \
|
||||||
# /data is the SQLite database mount point.
|
chown -R mcias:mcias /srv/mcias && \
|
||||||
RUN mkdir -p /etc/mcias /data && \
|
chmod 0750 /srv/mcias
|
||||||
chown mcias:mcias /data && \
|
|
||||||
chmod 0750 /data
|
|
||||||
|
|
||||||
# Declare /data as a volume so the operator must explicitly mount it.
|
# Declare /srv/mcias as a volume so the operator must explicitly mount it.
|
||||||
# The SQLite database must persist across container restarts.
|
# Contains the config file, TLS cert/key, and SQLite database.
|
||||||
VOLUME /data
|
VOLUME /srv/mcias
|
||||||
|
|
||||||
# REST/TLS port and gRPC/TLS port. These are documentation only; the actual
|
# REST/TLS port and gRPC/TLS port. These are documentation only; the actual
|
||||||
# ports are set in the config file. Override by mounting a different config.
|
# ports are set in the config file. Override by mounting a different config.
|
||||||
@@ -93,7 +88,8 @@ EXPOSE 9443
|
|||||||
USER mcias
|
USER mcias
|
||||||
|
|
||||||
# Default entry point and config path.
|
# Default entry point and config path.
|
||||||
# The operator mounts /etc/mcias/mcias.conf from the host or a volume.
|
# The operator mounts /srv/mcias from the host containing mcias.toml,
|
||||||
# See dist/mcias.conf.docker.example for a suitable template.
|
# TLS cert/key, and the SQLite database.
|
||||||
|
# See deploy/examples/mcias.conf.docker.example for a suitable template.
|
||||||
ENTRYPOINT ["mciassrv"]
|
ENTRYPOINT ["mciassrv"]
|
||||||
CMD ["-config", "/etc/mcias/mcias.conf"]
|
CMD ["-config", "/srv/mcias/mcias.toml"]
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ expose the same API surface:
|
|||||||
|
|
||||||
| Language | Location | Install |
|
| 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` |
|
| Python | `clients/python/` | `pip install ./clients/python` |
|
||||||
| Rust | `clients/rust/` | `cargo add mcias-client` |
|
| Rust | `clients/rust/` | `cargo add mcias-client` |
|
||||||
| Common Lisp | `clients/lisp/` | ASDF `mcias-client` |
|
| Common Lisp | `clients/lisp/` | ASDF `mcias-client` |
|
||||||
@@ -389,7 +389,7 @@ expose the same API surface:
|
|||||||
### Go
|
### Go
|
||||||
|
|
||||||
```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", "")
|
c, err := mcias.New("https://auth.example.com:8443", "/etc/mcias/server.crt", "")
|
||||||
if err != nil { ... }
|
if err != nil { ... }
|
||||||
|
|||||||
92
Makefile
92
Makefile
@@ -3,18 +3,23 @@
|
|||||||
# Usage:
|
# Usage:
|
||||||
# make build — compile all binaries to bin/
|
# make build — compile all binaries to bin/
|
||||||
# make test — run tests with race detector
|
# make test — run tests with race detector
|
||||||
|
# make vet — run go vet
|
||||||
# make lint — run golangci-lint
|
# make lint — run golangci-lint
|
||||||
|
# make all — vet → lint → test → build (CI pipeline)
|
||||||
# make generate — regenerate protobuf stubs (requires protoc)
|
# make generate — regenerate protobuf stubs (requires protoc)
|
||||||
|
# make proto-lint — lint proto files with buf
|
||||||
# make man — build compressed man pages
|
# make man — build compressed man pages
|
||||||
# make install — run dist/install.sh (requires root)
|
# make install — run deploy/scripts/install.sh (requires root)
|
||||||
|
# make devserver — build and run mciassrv against run/ config
|
||||||
# make clean — remove bin/ and generated artifacts
|
# make clean — remove bin/ and generated artifacts
|
||||||
# make dist — build release tarballs for linux/amd64 and linux/arm64
|
# make dist — build release tarballs for linux/amd64 and linux/arm64
|
||||||
# make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest
|
# make docker — build Docker image tagged mcias:$(VERSION) and mcias:latest
|
||||||
|
# make docker-clean — remove local mcias Docker images
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Variables
|
# Variables
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
MODULE := git.wntrmute.dev/kyle/mcias
|
MODULE := git.wntrmute.dev/mc/mcias
|
||||||
BINARIES := mciassrv mciasctl mciasdb mciasgrpcctl
|
BINARIES := mciassrv mciasctl mciasdb mciasgrpcctl
|
||||||
BIN_DIR := bin
|
BIN_DIR := bin
|
||||||
MAN_DIR := man/man1
|
MAN_DIR := man/man1
|
||||||
@@ -26,20 +31,25 @@ MAN_PAGES := $(MAN_DIR)/mciassrv.1 $(MAN_DIR)/mciasctl.1 \
|
|||||||
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
|
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||||
|
|
||||||
# Build flags: trim paths from binaries and strip DWARF/symbol table.
|
# Build flags: trim paths from binaries and strip DWARF/symbol table.
|
||||||
# CGO_ENABLED=1 is required for modernc.org/sqlite.
|
# modernc.org/sqlite is pure-Go and does not require CGo; CGO_ENABLED=0
|
||||||
|
# produces statically linked binaries that deploy cleanly to Alpine containers.
|
||||||
GO := go
|
GO := go
|
||||||
GOFLAGS := -trimpath
|
GOFLAGS := -trimpath
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
CGO := CGO_ENABLED=1
|
CGO := CGO_ENABLED=0
|
||||||
|
|
||||||
|
# The race detector requires CGo on some platforms, so tests continue to use
|
||||||
|
# CGO_ENABLED=1 while production builds are CGO_ENABLED=0.
|
||||||
|
CGO_TEST := CGO_ENABLED=1
|
||||||
|
|
||||||
# Platforms for cross-compiled dist tarballs.
|
# Platforms for cross-compiled dist tarballs.
|
||||||
DIST_PLATFORMS := linux/amd64 linux/arm64
|
DIST_PLATFORMS := linux/amd64 linux/arm64
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Default target
|
# Default target — CI pipeline: vet → lint → test → build
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
.PHONY: all
|
.PHONY: all
|
||||||
all: build
|
all: vet lint test build
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# build — compile all binaries to bin/
|
# build — compile all binaries to bin/
|
||||||
@@ -58,7 +68,14 @@ build:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test:
|
test:
|
||||||
$(CGO) $(GO) test -race ./...
|
$(CGO_TEST) $(GO) test -race ./...
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# vet — static analysis via go vet
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
.PHONY: vet
|
||||||
|
vet:
|
||||||
|
$(GO) vet ./...
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# lint — run golangci-lint
|
# lint — run golangci-lint
|
||||||
@@ -67,6 +84,15 @@ test:
|
|||||||
lint:
|
lint:
|
||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# proto-lint — lint and check for breaking changes in proto definitions
|
||||||
|
# Requires: buf (https://buf.build/docs/installation)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
.PHONY: proto-lint
|
||||||
|
proto-lint:
|
||||||
|
buf lint
|
||||||
|
buf breaking --against '.git#branch=master,subdir=proto'
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# generate — regenerate protobuf stubs from proto/ definitions
|
# generate — regenerate protobuf stubs from proto/ definitions
|
||||||
# Requires: protoc, protoc-gen-go, protoc-gen-go-grpc
|
# Requires: protoc, protoc-gen-go, protoc-gen-go-grpc
|
||||||
@@ -75,6 +101,13 @@ lint:
|
|||||||
generate:
|
generate:
|
||||||
$(GO) generate ./...
|
$(GO) generate ./...
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# devserver — build and run mciassrv against the local run/ config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
.PHONY: devserver
|
||||||
|
devserver: build
|
||||||
|
$(BIN_DIR)/mciassrv -config run/mcias.conf
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# man — build compressed man pages
|
# man — build compressed man pages
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -89,7 +122,7 @@ man: $(patsubst %.1,%.1.gz,$(MAN_PAGES))
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: build
|
install: build
|
||||||
sh dist/install.sh
|
sh deploy/scripts/install.sh
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# clean — remove build artifacts
|
# clean — remove build artifacts
|
||||||
@@ -97,14 +130,16 @@ install: build
|
|||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BIN_DIR)
|
rm -rf $(BIN_DIR)
|
||||||
|
rm -rf dist/
|
||||||
rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES))
|
rm -f $(patsubst %.1,%.1.gz,$(MAN_PAGES))
|
||||||
|
-docker rmi mcias:$(VERSION) mcias:latest 2>/dev/null || true
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# dist — cross-compiled release tarballs for linux/amd64 and linux/arm64
|
# dist — cross-compiled release tarballs for linux/amd64 and linux/arm64
|
||||||
#
|
#
|
||||||
# Output files: dist/mcias_<version>_<os>_<arch>.tar.gz
|
# Output files: dist/mcias_<version>_<os>_<arch>.tar.gz
|
||||||
# Each tarball contains: mciassrv, mciasctl, mciasdb, mciasgrpcctl,
|
# Each tarball contains: mciassrv, mciasctl, mciasdb, mciasgrpcctl,
|
||||||
# man pages, and dist/ files.
|
# man pages, and deploy/ files.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
.PHONY: dist
|
.PHONY: dist
|
||||||
dist: man
|
dist: man
|
||||||
@@ -115,14 +150,12 @@ dist: man
|
|||||||
echo " DIST $$platform -> $$outdir.tar.gz"; \
|
echo " DIST $$platform -> $$outdir.tar.gz"; \
|
||||||
mkdir -p $$outdir/bin; \
|
mkdir -p $$outdir/bin; \
|
||||||
for bin in $(BINARIES); do \
|
for bin in $(BINARIES); do \
|
||||||
CGO_ENABLED=1 GOOS=$$os GOARCH=$$arch $(GO) build \
|
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build \
|
||||||
$(GOFLAGS) -ldflags "$(LDFLAGS)" \
|
$(GOFLAGS) -ldflags "$(LDFLAGS)" \
|
||||||
-o $$outdir/bin/$$bin ./cmd/$$bin; \
|
-o $$outdir/bin/$$bin ./cmd/$$bin; \
|
||||||
done; \
|
done; \
|
||||||
cp -r man $$outdir/; \
|
cp -r man $$outdir/; \
|
||||||
cp dist/mcias.conf.example dist/mcias-dev.conf.example \
|
cp -r deploy $$outdir/; \
|
||||||
dist/mcias.env.example dist/mcias.service \
|
|
||||||
dist/install.sh $$outdir/; \
|
|
||||||
tar -czf $$outdir.tar.gz -C dist mcias_$$(echo $(VERSION) | tr -d 'v')_$${os}_$${arch}; \
|
tar -czf $$outdir.tar.gz -C dist mcias_$$(echo $(VERSION) | tr -d 'v')_$${os}_$${arch}; \
|
||||||
rm -rf $$outdir; \
|
rm -rf $$outdir; \
|
||||||
done
|
done
|
||||||
@@ -132,7 +165,15 @@ dist: man
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
.PHONY: docker
|
.PHONY: docker
|
||||||
docker:
|
docker:
|
||||||
docker build -t mcias:$(VERSION) -t mcias:latest .
|
docker build --force-rm -t mcias:$(VERSION) -t mcias:latest .
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# docker-clean — remove local mcias Docker images
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
.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
|
.PHONY: install-local
|
||||||
install-local: build
|
install-local: build
|
||||||
@@ -144,12 +185,17 @@ install-local: build
|
|||||||
.PHONY: help
|
.PHONY: help
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@echo " build Compile all binaries to bin/"
|
@echo " all vet → lint → test → build (CI pipeline)"
|
||||||
@echo " test Run tests with race detector"
|
@echo " build Compile all binaries to bin/"
|
||||||
@echo " lint Run golangci-lint"
|
@echo " test Run tests with race detector"
|
||||||
@echo " generate Regenerate protobuf stubs"
|
@echo " vet Run go vet"
|
||||||
@echo " man Build compressed man pages"
|
@echo " lint Run golangci-lint"
|
||||||
@echo " install Install to /usr/local/bin (requires root)"
|
@echo " proto-lint Lint proto files with buf"
|
||||||
@echo " clean Remove build artifacts"
|
@echo " generate Regenerate protobuf stubs"
|
||||||
@echo " dist Build release tarballs for Linux amd64/arm64"
|
@echo " devserver Build and run mciassrv against run/ config"
|
||||||
@echo " docker Build Docker image mcias:$(VERSION) and mcias:latest"
|
@echo " man Build compressed man pages"
|
||||||
|
@echo " install Install to /usr/local/bin (requires root)"
|
||||||
|
@echo " clean Remove build artifacts"
|
||||||
|
@echo " dist Build release tarballs for Linux amd64/arm64"
|
||||||
|
@echo " docker Build Docker image mcias:$(VERSION) and mcias:latest"
|
||||||
|
@echo " docker-clean Remove local mcias Docker images"
|
||||||
|
|||||||
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.
|
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
|
### 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
|
### Step 0.1: Go module and dependency setup
|
||||||
**Acceptance criteria:**
|
**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),
|
- Required dependencies declared: `modernc.org/sqlite` (CGo-free SQLite),
|
||||||
`golang.org/x/crypto` (Argon2, Ed25519 helpers), `github.com/golang-jwt/jwt/v5`,
|
`golang.org/x/crypto` (Argon2, Ed25519 helpers), `github.com/golang-jwt/jwt/v5`,
|
||||||
`github.com/pelletier/go-toml/v2`, `github.com/google/uuid`,
|
`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
|
### Step 1.1: `internal/model` — shared data types
|
||||||
**Acceptance criteria:**
|
**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
|
### Step 2.1: `internal/token` — JWT issuance and validation
|
||||||
**Acceptance criteria:**
|
**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
|
### Step 3.1: `internal/middleware` — HTTP middleware
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
@@ -143,6 +155,7 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
- `POST /v1/auth/totp/confirm` — confirms TOTP enrollment
|
- `POST /v1/auth/totp/confirm` — confirms TOTP enrollment
|
||||||
- `DELETE /v1/auth/totp` — admin; removes TOTP from account
|
- `DELETE /v1/auth/totp` — admin; removes TOTP from account
|
||||||
- `GET|PUT /v1/accounts/{id}/pgcreds` — get/set Postgres credentials
|
- `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
|
- Credential fields (password hash, TOTP secret, Postgres password) are
|
||||||
**never** included in any API response
|
**never** included in any API response
|
||||||
- Tests: each endpoint happy path; auth middleware applied correctly; invalid
|
- 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
|
### Step 4.1: `cmd/mciasctl` — admin CLI
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
@@ -177,6 +190,7 @@ See ARCHITECTURE.md for design rationale.
|
|||||||
- `mciasctl role revoke -id UUID -role ROLE`
|
- `mciasctl role revoke -id UUID -role ROLE`
|
||||||
- `mciasctl token issue -id UUID` (system accounts)
|
- `mciasctl token issue -id UUID` (system accounts)
|
||||||
- `mciasctl token revoke -jti JTI`
|
- `mciasctl token revoke -jti JTI`
|
||||||
|
- `mciasctl pgcreds list`
|
||||||
- `mciasctl pgcreds set -id UUID -host H -port P -db D -user U`
|
- `mciasctl pgcreds set -id UUID -host H -port P -db D -user U`
|
||||||
- `mciasctl pgcreds get -id UUID`
|
- `mciasctl pgcreds get -id UUID`
|
||||||
- `mciasctl auth login`
|
- `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
|
### Step 5.1: End-to-end test suite
|
||||||
**Acceptance criteria:**
|
**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
|
See ARCHITECTURE.md §16 for full design rationale, trust model, and command
|
||||||
surface.
|
surface.
|
||||||
@@ -314,9 +328,7 @@ surface.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
## Phase 7 — gRPC Interface **[COMPLETE]**
|
||||||
|
|
||||||
## Phase 7 — gRPC Interface
|
|
||||||
|
|
||||||
See ARCHITECTURE.md §17 for full design rationale, proto definitions, and
|
See ARCHITECTURE.md §17 for full design rationale, proto definitions, and
|
||||||
transport security requirements.
|
transport security requirements.
|
||||||
@@ -324,7 +336,8 @@ transport security requirements.
|
|||||||
### Step 7.1: Protobuf definitions and generated code
|
### Step 7.1: Protobuf definitions and generated code
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
- `proto/mcias/v1/` directory contains `.proto` files for all service groups:
|
- `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)
|
- All RPC methods mirror the REST API surface (see ARCHITECTURE.md §8 and §17)
|
||||||
- `proto/generate.go` contains a `//go:generate protoc ...` directive that
|
- `proto/generate.go` contains a `//go:generate protoc ...` directive that
|
||||||
produces Go stubs under `gen/mcias/v1/` using `protoc-gen-go` and
|
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
|
- gRPC server uses the same TLS certificate and key as the REST server (loaded
|
||||||
from config); minimum TLS 1.2 enforced via `tls.Config`
|
from config); minimum TLS 1.2 enforced via `tls.Config`
|
||||||
- Unary server interceptor chain:
|
- Unary server interceptor chain:
|
||||||
1. Request logger (method name, peer IP, status, duration)
|
1. Sealed interceptor (blocks all RPCs when vault sealed, except Health)
|
||||||
2. Auth interceptor (extracts Bearer token, validates, injects claims into
|
2. Request logger (method name, peer IP, status, duration)
|
||||||
|
3. Auth interceptor (extracts Bearer token, validates, injects claims into
|
||||||
`context.Context`)
|
`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
|
- No credential material logged by any interceptor
|
||||||
- Tests: interceptor chain applied correctly; rate-limit triggers after burst
|
- 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.
|
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)
|
- `generate` — `go generate ./...` (proto stubs from Phase 7)
|
||||||
- `man` — build compressed man pages
|
- `man` — build compressed man pages
|
||||||
- `install` — run `dist/install.sh`
|
- `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
|
- `dist` — build release tarballs for linux/amd64 and linux/arm64 (using
|
||||||
`GOOS`/`GOARCH` cross-compilation)
|
`GOOS`/`GOARCH` cross-compilation)
|
||||||
- `make build` works from a clean checkout after `go mod download`
|
- `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
|
- `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"`,
|
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/`
|
`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:
|
- Tests:
|
||||||
- `docker build .` completes without error (run in CI if Docker available;
|
- `docker build .` completes without error (run in CI if Docker available;
|
||||||
skip gracefully if not)
|
skip gracefully if not)
|
||||||
- `docker run --rm mcias:latest mciassrv --help` exits 0
|
- `docker run --rm mcias:latest mciassrv --help` exits 0
|
||||||
- Image size documented in PROGRESS.md (target: under 50 MB)
|
|
||||||
|
|
||||||
### Step 8.7: Documentation
|
### Step 8.7: Documentation
|
||||||
**Acceptance criteria:**
|
**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
|
See ARCHITECTURE.md §19 for full design rationale, API surface, and per-language
|
||||||
implementation notes.
|
implementation notes.
|
||||||
@@ -529,7 +543,7 @@ implementation notes.
|
|||||||
|
|
||||||
### Step 9.2: Go client library
|
### Step 9.2: Go client library
|
||||||
**Acceptance criteria:**
|
**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
|
- Package `mciasgoclient` exposes the canonical API surface from Step 9.1
|
||||||
- Uses `net/http` with `crypto/tls`; custom CA cert supported via `x509.CertPool`
|
- Uses `net/http` with `crypto/tls`; custom CA cert supported via `x509.CertPool`
|
||||||
- Token stored in-memory; `Client.Token()` accessor returns current token
|
- 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
|
## 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 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 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 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.
|
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).
|
**Prerequisites:** Go 1.26+, a C compiler (required by modernc.org/sqlite).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.wntrmute.dev/kyle/mcias
|
git clone https://git.wntrmute.dev/mc/mcias
|
||||||
cd mcias
|
cd mcias
|
||||||
make build # produces bin/mciassrv, other binaries
|
make build # produces bin/mciassrv, other binaries
|
||||||
sudo make install
|
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
|
## Master Key Rotation
|
||||||
|
|
||||||
> This operation is not yet automated. Until a rotation command is
|
> This operation is not yet automated. Until a rotation command is
|
||||||
@@ -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
|
## Troubleshooting
|
||||||
|
|
||||||
### Server fails to start: "open database"
|
### 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) |
|
| `MciasConflictError` | 409 | Conflict (e.g. duplicate username) |
|
||||||
| `MciasServerError` | 5xx | Unexpected server error |
|
| `MciasServerError` | 5xx | Unexpected server error |
|
||||||
`testdata/` contains canonical JSON response fixtures shared across language tests.
|
`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`
|
- `rust/` — Rust crate `mcias-client`
|
||||||
- `lisp/` — ASDF system `mcias-client`
|
- `lisp/` — ASDF system `mcias-client`
|
||||||
- `python/` — Python package `mcias_client`
|
- `python/` — Python package `mcias_client`
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ Go client library for the [MCIAS](../../README.md) identity and access managemen
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.wntrmute.dev/kyle/mcias/clients/go
|
go get git.wntrmute.dev/mc/mcias/clients/go
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "git.wntrmute.dev/kyle/mcias/clients/go/mcias"
|
import "git.wntrmute.dev/mc/mcias/clients/go/mcias"
|
||||||
|
|
||||||
// Connect to the MCIAS server.
|
// Connect to the MCIAS server.
|
||||||
client, err := mcias.New("https://auth.example.com", mcias.Options{})
|
client, err := mcias.New("https://auth.example.com", mcias.Options{})
|
||||||
|
|||||||
@@ -185,16 +185,28 @@ type Options struct {
|
|||||||
CACertPath string
|
CACertPath string
|
||||||
// Token is an optional pre-existing bearer token.
|
// Token is an optional pre-existing bearer token.
|
||||||
Token string
|
Token string
|
||||||
|
// ServiceName is the name of this service as registered in MCIAS. It is
|
||||||
|
// sent with every Login call so MCIAS can evaluate service-context policy
|
||||||
|
// rules (e.g. deny guest users from logging into this service).
|
||||||
|
// Populate from [mcias] service_name in the service's config file.
|
||||||
|
ServiceName string
|
||||||
|
// Tags are the service-level tags sent with every Login call. MCIAS
|
||||||
|
// evaluates auth:login policy against these tags, enabling rules such as
|
||||||
|
// "deny guest accounts from services tagged env:restricted".
|
||||||
|
// Populate from [mcias] tags in the service's config file.
|
||||||
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client is a thread-safe MCIAS REST API client.
|
// Client is a thread-safe MCIAS REST API client.
|
||||||
// Security: the bearer token is guarded by a sync.RWMutex; it is never
|
// Security: the bearer token is guarded by a sync.RWMutex; it is never
|
||||||
// written to logs or included in error messages in this library.
|
// written to logs or included in error messages in this library.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
mu sync.RWMutex
|
serviceName string
|
||||||
token string
|
tags []string
|
||||||
|
mu sync.RWMutex
|
||||||
|
token string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -224,9 +236,11 @@ func New(serverURL string, opts Options) (*Client, error) {
|
|||||||
}
|
}
|
||||||
transport := &http.Transport{TLSClientConfig: tlsCfg}
|
transport := &http.Transport{TLSClientConfig: tlsCfg}
|
||||||
c := &Client{
|
c := &Client{
|
||||||
baseURL: serverURL,
|
baseURL: serverURL,
|
||||||
http: &http.Client{Transport: transport},
|
http: &http.Client{Transport: transport},
|
||||||
token: opts.Token,
|
token: opts.Token,
|
||||||
|
serviceName: opts.ServiceName,
|
||||||
|
tags: opts.Tags,
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
@@ -343,16 +357,28 @@ func (c *Client) GetPublicKey() (*PublicKey, error) {
|
|||||||
// Login authenticates with username and password. On success the token is
|
// Login authenticates with username and password. On success the token is
|
||||||
// stored in the Client and returned along with the expiry timestamp.
|
// stored in the Client and returned along with the expiry timestamp.
|
||||||
// totpCode may be empty for accounts without TOTP.
|
// totpCode may be empty for accounts without TOTP.
|
||||||
|
//
|
||||||
|
// The client's ServiceName and Tags (from Options) are included in the
|
||||||
|
// request so MCIAS can evaluate service-context policy rules.
|
||||||
func (c *Client) Login(username, password, totpCode string) (token, expiresAt string, err error) {
|
func (c *Client) Login(username, password, totpCode string) (token, expiresAt string, err error) {
|
||||||
req := map[string]string{"username": username, "password": password}
|
body := map[string]interface{}{
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}
|
||||||
if totpCode != "" {
|
if totpCode != "" {
|
||||||
req["totp_code"] = totpCode
|
body["totp_code"] = totpCode
|
||||||
|
}
|
||||||
|
if c.serviceName != "" {
|
||||||
|
body["service_name"] = c.serviceName
|
||||||
|
}
|
||||||
|
if len(c.tags) > 0 {
|
||||||
|
body["tags"] = c.tags
|
||||||
}
|
}
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
if err := c.do(http.MethodPost, "/v1/auth/login", req, &resp); err != nil {
|
if err := c.do(http.MethodPost, "/v1/auth/login", body, &resp); err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
c.setToken(resp.Token)
|
c.setToken(resp.Token)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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
|
go 1.21
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ class Client:
|
|||||||
ca_cert_path: str | None = None,
|
ca_cert_path: str | None = None,
|
||||||
token: str | None = None,
|
token: str | None = None,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
|
service_name: str | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._base_url = server_url.rstrip("/")
|
self._base_url = server_url.rstrip("/")
|
||||||
self.token = token
|
self.token = token
|
||||||
|
self._service_name = service_name
|
||||||
|
self._tags = tags or []
|
||||||
ssl_context: ssl.SSLContext | bool
|
ssl_context: ssl.SSLContext | bool
|
||||||
if ca_cert_path is not None:
|
if ca_cert_path is not None:
|
||||||
ssl_context = ssl.create_default_context(cafile=ca_cert_path)
|
ssl_context = ssl.create_default_context(cafile=ca_cert_path)
|
||||||
@@ -115,6 +119,9 @@ class Client:
|
|||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""POST /v1/auth/login — authenticate and obtain a JWT.
|
"""POST /v1/auth/login — authenticate and obtain a JWT.
|
||||||
Returns (token, expires_at). Stores the token on self.token.
|
Returns (token, expires_at). Stores the token on self.token.
|
||||||
|
|
||||||
|
The client's service_name and tags are included so MCIAS can evaluate
|
||||||
|
service-context policy rules (e.g. deny guests from restricted services).
|
||||||
"""
|
"""
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"username": username,
|
"username": username,
|
||||||
@@ -122,6 +129,10 @@ class Client:
|
|||||||
}
|
}
|
||||||
if totp_code is not None:
|
if totp_code is not None:
|
||||||
payload["totp_code"] = totp_code
|
payload["totp_code"] = totp_code
|
||||||
|
if self._service_name is not None:
|
||||||
|
payload["service_name"] = self._service_name
|
||||||
|
if self._tags:
|
||||||
|
payload["tags"] = self._tags
|
||||||
data = self._request("POST", "/v1/auth/login", json=payload)
|
data = self._request("POST", "/v1/auth/login", json=payload)
|
||||||
assert data is not None
|
assert data is not None
|
||||||
token = str(data["token"])
|
token = str(data["token"])
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ struct LoginRequest<'a> {
|
|||||||
password: &'a str,
|
password: &'a str,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
totp_code: Option<&'a str>,
|
totp_code: Option<&'a str>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
service_name: Option<&'a str>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -268,6 +272,16 @@ pub struct ClientOptions {
|
|||||||
|
|
||||||
/// Optional pre-existing bearer token.
|
/// Optional pre-existing bearer token.
|
||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
|
|
||||||
|
/// This service's name as registered in MCIAS. Sent with every login
|
||||||
|
/// request so MCIAS can evaluate service-context policy rules.
|
||||||
|
/// Populate from `[mcias] service_name` in the service config.
|
||||||
|
pub service_name: Option<String>,
|
||||||
|
|
||||||
|
/// Service-level tags sent with every login request. MCIAS evaluates
|
||||||
|
/// `auth:login` policy against these tags.
|
||||||
|
/// Populate from `[mcias] tags` in the service config.
|
||||||
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Client ----
|
// ---- Client ----
|
||||||
@@ -280,6 +294,8 @@ pub struct ClientOptions {
|
|||||||
pub struct Client {
|
pub struct Client {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
|
service_name: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
|
/// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
|
||||||
/// Security: the token is never logged or included in error messages.
|
/// Security: the token is never logged or included in error messages.
|
||||||
token: Arc<RwLock<Option<String>>>,
|
token: Arc<RwLock<Option<String>>>,
|
||||||
@@ -306,6 +322,8 @@ impl Client {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
base_url: base_url.trim_end_matches('/').to_owned(),
|
base_url: base_url.trim_end_matches('/').to_owned(),
|
||||||
http,
|
http,
|
||||||
|
service_name: opts.service_name,
|
||||||
|
tags: opts.tags,
|
||||||
token: Arc::new(RwLock::new(opts.token)),
|
token: Arc::new(RwLock::new(opts.token)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -336,6 +354,8 @@ impl Client {
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
totp_code,
|
totp_code,
|
||||||
|
service_name: self.service_name.as_deref(),
|
||||||
|
tags: self.tags.clone(),
|
||||||
};
|
};
|
||||||
let resp: TokenResponse = self.post("/v1/auth/login", &body).await?;
|
let resp: TokenResponse = self.post("/v1/auth/login", &body).await?;
|
||||||
*self.token.write().await = Some(resp.token.clone());
|
*self.token.write().await = Some(resp.token.clone());
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (t *tool) runAccount(args []string) {
|
func (t *tool) runAccount(args []string) {
|
||||||
if len(args) == 0 {
|
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] {
|
switch args[0] {
|
||||||
case "list":
|
case "list":
|
||||||
@@ -28,6 +28,8 @@ func (t *tool) runAccount(args []string) {
|
|||||||
t.accountSetStatus(args[1:])
|
t.accountSetStatus(args[1:])
|
||||||
case "reset-totp":
|
case "reset-totp":
|
||||||
t.accountResetTOTP(args[1:])
|
t.accountResetTOTP(args[1:])
|
||||||
|
case "reset-webauthn":
|
||||||
|
t.webauthnReset(args[1:])
|
||||||
default:
|
default:
|
||||||
fatalf("unknown account subcommand %q", args[0])
|
fatalf("unknown account subcommand %q", args[0])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (t *tool) runAudit(args []string) {
|
func (t *tool) runAudit(args []string) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
// pgcreds get --id UUID
|
// pgcreds get --id UUID
|
||||||
// pgcreds set --id UUID --host H --port P --db D --user U
|
// pgcreds set --id UUID --host H --port P --db D --user U
|
||||||
//
|
//
|
||||||
// rekey
|
// snapshot [--retain-days N]
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -49,9 +49,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -68,6 +68,14 @@ func main() {
|
|||||||
command := args[0]
|
command := args[0]
|
||||||
subArgs := args[1:]
|
subArgs := args[1:]
|
||||||
|
|
||||||
|
// snapshot loads only the config (no master key needed — VACUUM INTO does
|
||||||
|
// not access encrypted columns) and must be handled before openDB, which
|
||||||
|
// requires the master key passphrase env var.
|
||||||
|
if command == "snapshot" {
|
||||||
|
runSnapshot(*configPath, subArgs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// schema subcommands manage migrations themselves and must not trigger
|
// schema subcommands manage migrations themselves and must not trigger
|
||||||
// auto-migration on open (a dirty database would prevent the tool from
|
// auto-migration on open (a dirty database would prevent the tool from
|
||||||
// opening at all, blocking recovery operations like "schema force").
|
// opening at all, blocking recovery operations like "schema force").
|
||||||
@@ -109,6 +117,8 @@ func main() {
|
|||||||
tool.runAudit(subArgs)
|
tool.runAudit(subArgs)
|
||||||
case "pgcreds":
|
case "pgcreds":
|
||||||
tool.runPGCreds(subArgs)
|
tool.runPGCreds(subArgs)
|
||||||
|
case "webauthn":
|
||||||
|
tool.runWebAuthn(subArgs)
|
||||||
case "rekey":
|
case "rekey":
|
||||||
tool.runRekey(subArgs)
|
tool.runRekey(subArgs)
|
||||||
default:
|
default:
|
||||||
@@ -245,6 +255,11 @@ Commands:
|
|||||||
account set-password --id UUID (prompts interactively)
|
account set-password --id UUID (prompts interactively)
|
||||||
account set-status --id UUID --status active|inactive|deleted
|
account set-status --id UUID --status active|inactive|deleted
|
||||||
account reset-totp --id UUID
|
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 list --id UUID
|
||||||
role grant --id UUID --role ROLE
|
role grant --id UUID --role ROLE
|
||||||
@@ -266,6 +281,11 @@ Commands:
|
|||||||
rekey Re-encrypt all secrets under a new master passphrase
|
rekey Re-encrypt all secrets under a new master passphrase
|
||||||
(prompts interactively; requires server to be stopped)
|
(prompts interactively; requires server to be stopped)
|
||||||
|
|
||||||
|
snapshot Write a timestamped VACUUM INTO backup to
|
||||||
|
<db-dir>/backups/; prune backups older than
|
||||||
|
--retain-days days (default 30, 0 = keep all).
|
||||||
|
Does not require the master key passphrase.
|
||||||
|
|
||||||
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
|
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
|
||||||
file. Use it only when the server is unavailable or for break-glass recovery.
|
file. Use it only when the server is unavailable or for break-glass recovery.
|
||||||
All write operations are recorded in the audit log.
|
All write operations are recorded in the audit log.
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestTool creates a tool backed by an in-memory SQLite database with a
|
// newTestTool creates a tool backed by an in-memory SQLite database with a
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (t *tool) runPGCreds(args []string) {
|
func (t *tool) runPGCreds(args []string) {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runRekey re-encrypts all secrets under a new passphrase-derived master key.
|
// runRekey re-encrypts all secrets under a new passphrase-derived master key.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (t *tool) runSchema(args []string) {
|
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)
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ import (
|
|||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
|
|
||||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/grpcserver"
|
"git.wntrmute.dev/mc/mcias/internal/grpcserver"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/server"
|
"git.wntrmute.dev/mc/mcias/internal/server"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -87,17 +87,16 @@ func run(configPath string, logger *slog.Logger) error {
|
|||||||
masterKey, mkErr := loadMasterKey(cfg, database)
|
masterKey, mkErr := loadMasterKey(cfg, database)
|
||||||
if mkErr != nil {
|
if mkErr != nil {
|
||||||
// Check if we can start sealed (passphrase mode, empty env var).
|
// Check if we can start sealed (passphrase mode, empty env var).
|
||||||
if cfg.MasterKey.KeyFile == "" && os.Getenv(cfg.MasterKey.PassphraseEnv) == "" {
|
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 {
|
|
||||||
return fmt.Errorf("load master key: %w", mkErr)
|
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 {
|
} else {
|
||||||
// Load or generate the Ed25519 signing key.
|
// Load or generate the Ed25519 signing key.
|
||||||
// Security: The private signing key is stored AES-256-GCM encrypted in the
|
// Security: The private signing key is stored AES-256-GCM encrypted in the
|
||||||
|
|||||||
@@ -41,3 +41,10 @@ threads = 4
|
|||||||
|
|
||||||
[master_key]
|
[master_key]
|
||||||
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
||||||
|
|
||||||
|
# WebAuthn — passkey authentication for local development.
|
||||||
|
# rp_origin includes the non-standard port since we're not behind a proxy.
|
||||||
|
[webauthn]
|
||||||
|
rp_id = "localhost"
|
||||||
|
rp_origin = "https://localhost:8443"
|
||||||
|
display_name = "MCIAS (dev)"
|
||||||
@@ -48,3 +48,14 @@ threads = 4
|
|||||||
# Set it with: docker run -e MCIAS_MASTER_PASSPHRASE=your-passphrase ...
|
# Set it with: docker run -e MCIAS_MASTER_PASSPHRASE=your-passphrase ...
|
||||||
# or with a Docker secret / Kubernetes secret.
|
# or with a Docker secret / Kubernetes secret.
|
||||||
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# [webauthn] — FIDO2/WebAuthn passkey authentication (OPTIONAL)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Uncomment to enable passwordless passkey login. Set rp_id to your domain
|
||||||
|
# and rp_origin to the full HTTPS origin users access in their browser.
|
||||||
|
#
|
||||||
|
# [webauthn]
|
||||||
|
# rp_id = "auth.example.com"
|
||||||
|
# rp_origin = "https://auth.example.com"
|
||||||
|
# display_name = "MCIAS"
|
||||||
@@ -123,3 +123,24 @@ passphrase_env = "MCIAS_MASTER_PASSPHRASE"
|
|||||||
#
|
#
|
||||||
# Uncomment and comment out passphrase_env to switch modes.
|
# Uncomment and comment out passphrase_env to switch modes.
|
||||||
# keyfile = "/srv/mcias/master.key"
|
# keyfile = "/srv/mcias/master.key"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# [webauthn] — FIDO2/WebAuthn passkey authentication (OPTIONAL)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Enables passwordless passkey login and hardware security key 2FA.
|
||||||
|
# If this section is omitted or rp_id/rp_origin are empty, WebAuthn is
|
||||||
|
# disabled and passkey options will not appear in the UI.
|
||||||
|
#
|
||||||
|
# [webauthn]
|
||||||
|
#
|
||||||
|
# REQUIRED (if enabling). The Relying Party ID — typically the domain name
|
||||||
|
# (without port or scheme). Must match the domain users see in their browser.
|
||||||
|
# rp_id = "auth.example.com"
|
||||||
|
#
|
||||||
|
# REQUIRED (if enabling). The Relying Party Origin — the full origin URL
|
||||||
|
# including scheme. Must be HTTPS. Include the port if non-standard (not 443).
|
||||||
|
# rp_origin = "https://auth.example.com"
|
||||||
|
#
|
||||||
|
# OPTIONAL. Display name shown to users during passkey registration prompts.
|
||||||
|
# Default: "MCIAS".
|
||||||
|
# display_name = "MCIAS"
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# install.sh — MCIAS first-time and upgrade installer
|
# install.sh — MCIAS first-time and upgrade installer
|
||||||
#
|
#
|
||||||
# Usage: sh dist/install.sh
|
# Usage: sh deploy/scripts/install.sh
|
||||||
#
|
#
|
||||||
# This script must be run as root. It:
|
# This script must be run as root. It:
|
||||||
# 1. Creates the mcias system user and group (idempotent).
|
# 1. Creates the mcias system user and group (idempotent).
|
||||||
# 2. Copies binaries to /usr/local/bin/.
|
# 2. Copies binaries to /usr/local/bin/.
|
||||||
# 3. Creates /srv/mcias/ with correct permissions.
|
# 3. Creates /srv/mcias/ with correct permissions.
|
||||||
# 4. Installs the systemd service unit.
|
# 4. Installs the systemd service and backup units.
|
||||||
# 5. Prints post-install instructions.
|
# 5. Prints post-install instructions.
|
||||||
#
|
#
|
||||||
# The script does NOT start or enable the service automatically. Review the
|
# The script does NOT start or enable the service automatically. Review the
|
||||||
@@ -31,7 +31,8 @@ SYSTEMD_DIR="/etc/systemd/system"
|
|||||||
SERVICE_USER="mcias"
|
SERVICE_USER="mcias"
|
||||||
SERVICE_GROUP="mcias"
|
SERVICE_GROUP="mcias"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
REPO_ROOT="$(dirname "$DEPLOY_DIR")"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -100,11 +101,7 @@ fi
|
|||||||
# Step 2: Install binaries.
|
# Step 2: Install binaries.
|
||||||
info "Installing binaries to $BIN_DIR"
|
info "Installing binaries to $BIN_DIR"
|
||||||
for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
|
for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
|
||||||
src="$REPO_ROOT/$bin"
|
src="$REPO_ROOT/bin/$bin"
|
||||||
if [ ! -f "$src" ]; then
|
|
||||||
# Try bin/ subdirectory (Makefile build output).
|
|
||||||
src="$REPO_ROOT/bin/$bin"
|
|
||||||
fi
|
|
||||||
if [ ! -f "$src" ]; then
|
if [ ! -f "$src" ]; then
|
||||||
warn "Binary not found: $bin — skipping. Run 'make build' first."
|
warn "Binary not found: $bin — skipping. Run 'make build' first."
|
||||||
continue
|
continue
|
||||||
@@ -113,30 +110,40 @@ for bin in mciassrv mciasctl mciasdb mciasgrpcctl; do
|
|||||||
install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin"
|
install -m 0755 -o root -g root "$src" "$BIN_DIR/$bin"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Step 3: Create service directory.
|
# Step 3: Create service directory structure.
|
||||||
info "Creating $SRV_DIR"
|
info "Creating $SRV_DIR"
|
||||||
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR"
|
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR"
|
||||||
|
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR/certs"
|
||||||
|
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$SRV_DIR/backups"
|
||||||
|
|
||||||
# Install example config files; never overwrite existing configs.
|
# Install example config files; never overwrite existing configs.
|
||||||
for f in mcias.conf.example mcias.env.example; do
|
for f in mcias.conf.example mcias.env.example; do
|
||||||
src="$SCRIPT_DIR/$f"
|
src="$DEPLOY_DIR/examples/$f"
|
||||||
dst="$SRV_DIR/$f"
|
dst="$SRV_DIR/$f"
|
||||||
if [ -f "$src" ]; then
|
if [ -f "$src" ]; then
|
||||||
install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
|
install -m 0640 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$src" "$dst" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Step 5: Install systemd service unit.
|
# Step 4: Install systemd units.
|
||||||
if [ -d "$SYSTEMD_DIR" ]; then
|
if [ -d "$SYSTEMD_DIR" ]; then
|
||||||
info "Installing systemd service unit to $SYSTEMD_DIR"
|
info "Installing systemd units to $SYSTEMD_DIR"
|
||||||
install -m 0644 -o root -g root "$SCRIPT_DIR/mcias.service" "$SYSTEMD_DIR/mcias.service"
|
for unit in mcias.service mcias-backup.service mcias-backup.timer; do
|
||||||
|
src="$DEPLOY_DIR/systemd/$unit"
|
||||||
|
if [ -f "$src" ]; then
|
||||||
|
install -m 0644 -o root -g root "$src" "$SYSTEMD_DIR/$unit"
|
||||||
|
info " Installed $unit"
|
||||||
|
fi
|
||||||
|
done
|
||||||
info "Reloading systemd daemon"
|
info "Reloading systemd daemon"
|
||||||
systemctl daemon-reload 2>/dev/null || warn "systemctl not available; reload manually."
|
systemctl daemon-reload 2>/dev/null || warn "systemctl not available; reload manually."
|
||||||
|
info "Enabling backup timer"
|
||||||
|
systemctl enable mcias-backup.timer 2>/dev/null || warn "Could not enable timer; enable manually with: systemctl enable mcias-backup.timer"
|
||||||
else
|
else
|
||||||
warn "systemd not found at $SYSTEMD_DIR; skipping service unit installation."
|
warn "systemd not found at $SYSTEMD_DIR; skipping service unit installation."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 6: Install man pages.
|
# Step 5: Install man pages.
|
||||||
if [ -d "$REPO_ROOT/man/man1" ]; then
|
if [ -d "$REPO_ROOT/man/man1" ]; then
|
||||||
install -d -m 0755 -o root -g root "$MAN_DIR"
|
install -d -m 0755 -o root -g root "$MAN_DIR"
|
||||||
info "Installing man pages to $MAN_DIR"
|
info "Installing man pages to $MAN_DIR"
|
||||||
@@ -170,12 +177,12 @@ Next steps:
|
|||||||
|
|
||||||
# Self-signed (development / personal use):
|
# Self-signed (development / personal use):
|
||||||
openssl req -x509 -newkey ed25519 -days 3650 \\
|
openssl req -x509 -newkey ed25519 -days 3650 \\
|
||||||
-keyout /srv/mcias/server.key \\
|
-keyout /srv/mcias/certs/server.key \\
|
||||||
-out /srv/mcias/server.crt \\
|
-out /srv/mcias/certs/server.crt \\
|
||||||
-subj "/CN=auth.example.com" \\
|
-subj "/CN=auth.example.com" \\
|
||||||
-nodes
|
-nodes
|
||||||
chmod 0640 /srv/mcias/server.key
|
chmod 0640 /srv/mcias/certs/server.key
|
||||||
chown mcias:mcias /srv/mcias/server.key /srv/mcias/server.crt
|
chown mcias:mcias /srv/mcias/certs/server.key /srv/mcias/certs/server.crt
|
||||||
|
|
||||||
2. Copy and edit the configuration file:
|
2. Copy and edit the configuration file:
|
||||||
|
|
||||||
@@ -200,6 +207,9 @@ Next steps:
|
|||||||
systemctl start mcias
|
systemctl start mcias
|
||||||
systemctl status mcias
|
systemctl status mcias
|
||||||
|
|
||||||
|
The backup timer was enabled automatically. Verify with:
|
||||||
|
systemctl status mcias-backup.timer
|
||||||
|
|
||||||
5. Create the first admin account using mciasdb (while the server is
|
5. Create the first admin account using mciasdb (while the server is
|
||||||
running, or before first start):
|
running, or before first start):
|
||||||
|
|
||||||
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.
|
# Configuration and secrets.
|
||||||
# /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase>
|
# /srv/mcias/env must contain MCIAS_MASTER_PASSPHRASE=<passphrase>
|
||||||
# See dist/mcias.env.example for the template.
|
# See deploy/examples/mcias.env.example for the template.
|
||||||
EnvironmentFile=/srv/mcias/env
|
EnvironmentFile=/srv/mcias/env
|
||||||
|
|
||||||
ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml
|
ExecStart=/usr/local/bin/mciassrv -config /srv/mcias/mcias.toml
|
||||||
@@ -42,6 +42,7 @@ PrivateDevices=true
|
|||||||
ProtectKernelTunables=true
|
ProtectKernelTunables=true
|
||||||
ProtectKernelModules=true
|
ProtectKernelModules=true
|
||||||
ProtectControlGroups=true
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
RestrictNamespaces=true
|
RestrictNamespaces=true
|
||||||
RestrictRealtime=true
|
RestrictRealtime=true
|
||||||
LockPersonality=true
|
LockPersonality=true
|
||||||
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
|
||||||
|
}
|
||||||
34
flake.nix
Normal file
34
flake.nix
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
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.7.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}"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v6.33.4
|
// protoc v3.20.3
|
||||||
// source: mcias/v1/account.proto
|
// source: mcias/v1/account.proto
|
||||||
|
|
||||||
package mciasv1
|
package mciasv1
|
||||||
@@ -1080,7 +1080,7 @@ const file_mcias_v1_account_proto_rawDesc = "" +
|
|||||||
"\n" +
|
"\n" +
|
||||||
"GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\n" +
|
"GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\n" +
|
||||||
"\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 (
|
var (
|
||||||
file_mcias_v1_account_proto_rawDescOnce sync.Once
|
file_mcias_v1_account_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc v6.33.4
|
// - protoc v3.20.3
|
||||||
// source: mcias/v1/account.proto
|
// source: mcias/v1/account.proto
|
||||||
|
|
||||||
package mciasv1
|
package mciasv1
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ const file_mcias_v1_admin_proto_rawDesc = "" +
|
|||||||
"\x01x\x18\x05 \x01(\tR\x01x2\x9a\x01\n" +
|
"\x01x\x18\x05 \x01(\tR\x01x2\x9a\x01\n" +
|
||||||
"\fAdminService\x12;\n" +
|
"\fAdminService\x12;\n" +
|
||||||
"\x06Health\x12\x17.mcias.v1.HealthRequest\x1a\x18.mcias.v1.HealthResponse\x12M\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 (
|
var (
|
||||||
file_mcias_v1_admin_proto_rawDescOnce sync.Once
|
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}
|
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
|
var File_mcias_v1_auth_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_mcias_v1_auth_proto_rawDesc = "" +
|
const file_mcias_v1_auth_proto_rawDesc = "" +
|
||||||
@@ -601,7 +883,31 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
|
|||||||
"\x11RemoveTOTPRequest\x12\x1d\n" +
|
"\x11RemoveTOTPRequest\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"account_id\x18\x01 \x01(\tR\taccountId\"\x14\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" +
|
"\vAuthService\x128\n" +
|
||||||
"\x05Login\x12\x16.mcias.v1.LoginRequest\x1a\x17.mcias.v1.LoginResponse\x12;\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" +
|
"\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" +
|
"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" +
|
"\vConfirmTOTP\x12\x1c.mcias.v1.ConfirmTOTPRequest\x1a\x1d.mcias.v1.ConfirmTOTPResponse\x12G\n" +
|
||||||
"\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 (
|
var (
|
||||||
file_mcias_v1_auth_proto_rawDescOnce sync.Once
|
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
|
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{
|
var file_mcias_v1_auth_proto_goTypes = []any{
|
||||||
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
|
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
|
||||||
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
|
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
|
||||||
(*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest
|
(*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest
|
||||||
(*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse
|
(*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse
|
||||||
(*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest
|
(*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest
|
||||||
(*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse
|
(*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse
|
||||||
(*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest
|
(*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest
|
||||||
(*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse
|
(*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse
|
||||||
(*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest
|
(*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest
|
||||||
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
|
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
|
||||||
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
|
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
|
||||||
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
|
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
|
||||||
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
|
(*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{
|
var file_mcias_v1_auth_proto_depIdxs = []int32{
|
||||||
12, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
|
17, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||||
12, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
|
17, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
|
||||||
0, // 2: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
|
17, // 2: mcias.v1.WebAuthnCredentialInfo.created_at:type_name -> google.protobuf.Timestamp
|
||||||
2, // 3: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
|
17, // 3: mcias.v1.WebAuthnCredentialInfo.last_used_at:type_name -> google.protobuf.Timestamp
|
||||||
4, // 4: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
|
13, // 4: mcias.v1.ListWebAuthnCredentialsResponse.credentials:type_name -> mcias.v1.WebAuthnCredentialInfo
|
||||||
6, // 5: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
|
0, // 5: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
|
||||||
8, // 6: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
|
2, // 6: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
|
||||||
10, // 7: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
|
4, // 7: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
|
||||||
1, // 8: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
|
6, // 8: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
|
||||||
3, // 9: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
|
8, // 9: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
|
||||||
5, // 10: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
|
10, // 10: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
|
||||||
7, // 11: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
|
12, // 11: mcias.v1.AuthService.ListWebAuthnCredentials:input_type -> mcias.v1.ListWebAuthnCredentialsRequest
|
||||||
9, // 12: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
|
15, // 12: mcias.v1.AuthService.RemoveWebAuthnCredential:input_type -> mcias.v1.RemoveWebAuthnCredentialRequest
|
||||||
11, // 13: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse
|
1, // 13: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
|
||||||
8, // [8:14] is the sub-list for method output_type
|
3, // 14: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
|
||||||
2, // [2:8] is the sub-list for method input_type
|
5, // 15: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
|
||||||
2, // [2:2] is the sub-list for extension type_name
|
7, // 16: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
|
||||||
2, // [2:2] is the sub-list for extension extendee
|
9, // 17: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
|
||||||
0, // [0:2] is the sub-list for field type_name
|
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() }
|
func init() { file_mcias_v1_auth_proto_init() }
|
||||||
@@ -674,7 +994,7 @@ func file_mcias_v1_auth_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 12,
|
NumMessages: 17,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import (
|
|||||||
const _ = grpc.SupportPackageIsVersion9
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login"
|
AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login"
|
||||||
AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout"
|
AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout"
|
||||||
AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken"
|
AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken"
|
||||||
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
|
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
|
||||||
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
|
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
|
||||||
AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP"
|
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.
|
// AuthServiceClient is the client API for AuthService service.
|
||||||
@@ -53,6 +55,12 @@ type AuthServiceClient interface {
|
|||||||
// RemoveTOTP removes TOTP from an account (admin only).
|
// RemoveTOTP removes TOTP from an account (admin only).
|
||||||
// Requires: admin JWT in metadata.
|
// Requires: admin JWT in metadata.
|
||||||
RemoveTOTP(ctx context.Context, in *RemoveTOTPRequest, opts ...grpc.CallOption) (*RemoveTOTPResponse, error)
|
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 {
|
type authServiceClient struct {
|
||||||
@@ -123,6 +131,26 @@ func (c *authServiceClient) RemoveTOTP(ctx context.Context, in *RemoveTOTPReques
|
|||||||
return out, nil
|
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.
|
// AuthServiceServer is the server API for AuthService service.
|
||||||
// All implementations must embed UnimplementedAuthServiceServer
|
// All implementations must embed UnimplementedAuthServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -147,6 +175,12 @@ type AuthServiceServer interface {
|
|||||||
// RemoveTOTP removes TOTP from an account (admin only).
|
// RemoveTOTP removes TOTP from an account (admin only).
|
||||||
// Requires: admin JWT in metadata.
|
// Requires: admin JWT in metadata.
|
||||||
RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error)
|
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()
|
mustEmbedUnimplementedAuthServiceServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +209,12 @@ func (UnimplementedAuthServiceServer) ConfirmTOTP(context.Context, *ConfirmTOTPR
|
|||||||
func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) {
|
func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method RemoveTOTP not implemented")
|
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) mustEmbedUnimplementedAuthServiceServer() {}
|
||||||
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
|
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -304,6 +344,42 @@ func _AuthService_RemoveTOTP_Handler(srv interface{}, ctx context.Context, dec f
|
|||||||
return interceptor(ctx, in, info, handler)
|
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.
|
// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -335,6 +411,14 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "RemoveTOTP",
|
MethodName: "RemoveTOTP",
|
||||||
Handler: _AuthService_RemoveTOTP_Handler,
|
Handler: _AuthService_RemoveTOTP_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListWebAuthnCredentials",
|
||||||
|
Handler: _AuthService_ListWebAuthnCredentials_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RemoveWebAuthnCredential",
|
||||||
|
Handler: _AuthService_RemoveWebAuthnCredential_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{},
|
||||||
Metadata: "mcias/v1/auth.proto",
|
Metadata: "mcias/v1/auth.proto",
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ const file_mcias_v1_common_proto_rawDesc = "" +
|
|||||||
"\x04port\x18\x05 \x01(\x05R\x04port\"5\n" +
|
"\x04port\x18\x05 \x01(\x05R\x04port\"5\n" +
|
||||||
"\x05Error\x12\x18\n" +
|
"\x05Error\x12\x18\n" +
|
||||||
"\amessage\x18\x01 \x01(\tR\amessage\x12\x12\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 (
|
var (
|
||||||
file_mcias_v1_common_proto_rawDescOnce sync.Once
|
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" +
|
"\x10CreatePolicyRule\x12!.mcias.v1.CreatePolicyRuleRequest\x1a\".mcias.v1.CreatePolicyRuleResponse\x12P\n" +
|
||||||
"\rGetPolicyRule\x12\x1e.mcias.v1.GetPolicyRuleRequest\x1a\x1f.mcias.v1.GetPolicyRuleResponse\x12Y\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" +
|
"\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 (
|
var (
|
||||||
file_mcias_v1_policy_proto_rawDescOnce sync.Once
|
file_mcias_v1_policy_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ const file_mcias_v1_token_proto_rawDesc = "" +
|
|||||||
"\fTokenService\x12P\n" +
|
"\fTokenService\x12P\n" +
|
||||||
"\rValidateToken\x12\x1e.mcias.v1.ValidateTokenRequest\x1a\x1f.mcias.v1.ValidateTokenResponse\x12\\\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" +
|
"\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 (
|
var (
|
||||||
file_mcias_v1_token_proto_rawDescOnce sync.Once
|
file_mcias_v1_token_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
19
go.mod
19
go.mod
@@ -1,14 +1,16 @@
|
|||||||
module git.wntrmute.dev/kyle/mcias
|
module git.wntrmute.dev/mc/mcias
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-webauthn/webauthn v0.16.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
golang.org/x/crypto v0.45.0
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
golang.org/x/term v0.37.0
|
golang.org/x/crypto v0.49.0
|
||||||
|
golang.org/x/term v0.41.0
|
||||||
google.golang.org/grpc v1.74.2
|
google.golang.org/grpc v1.74.2
|
||||||
google.golang.org/protobuf v1.36.7
|
google.golang.org/protobuf v1.36.7
|
||||||
modernc.org/sqlite v1.46.1
|
modernc.org/sqlite v1.46.1
|
||||||
@@ -16,13 +18,18 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
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/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
54
go.sum
54
go.sum
@@ -2,10 +2,18 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
|||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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=
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
@@ -14,6 +22,10 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
|||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -32,8 +44,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
|
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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
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/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 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
@@ -46,25 +62,27 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw
|
|||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
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 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
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/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
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/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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
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 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/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 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
|
|
||||||
func TestJSON(t *testing.T) {
|
func TestJSON(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
verify func(t *testing.T, result string)
|
||||||
name string
|
name string
|
||||||
pairs []string
|
pairs []string
|
||||||
verify func(t *testing.T, result string)
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "single pair",
|
name: "single pair",
|
||||||
@@ -109,9 +109,9 @@ func TestJSON(t *testing.T) {
|
|||||||
|
|
||||||
func TestJSONWithRoles(t *testing.T) {
|
func TestJSONWithRoles(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
verify func(t *testing.T, result string)
|
||||||
name string
|
name string
|
||||||
roles []string
|
roles []string
|
||||||
verify func(t *testing.T, result string)
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "multiple roles",
|
name: "multiple roles",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"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.
|
// ErrInvalidCredentials is returned for any authentication failure.
|
||||||
|
|||||||
@@ -8,18 +8,29 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the top-level configuration structure parsed from the TOML file.
|
// 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"`
|
Server ServerConfig `toml:"server"`
|
||||||
MasterKey MasterKeyConfig `toml:"master_key"`
|
MasterKey MasterKeyConfig `toml:"master_key"`
|
||||||
Database DatabaseConfig `toml:"database"`
|
Database DatabaseConfig `toml:"database"`
|
||||||
Tokens TokensConfig `toml:"tokens"`
|
Tokens TokensConfig `toml:"tokens"`
|
||||||
Argon2 Argon2Config `toml:"argon2"`
|
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.
|
// 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
|
// generous to accommodate a range of legitimate deployments while
|
||||||
// catching obvious typos (e.g. "876000h" instead of "8760h").
|
// catching obvious typos (e.g. "876000h" instead of "8760h").
|
||||||
const (
|
const (
|
||||||
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
|
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
|
||||||
maxAdminExpiry = 24 * time.Hour // 24 hours
|
maxAdminExpiry = 24 * time.Hour // 24 hours
|
||||||
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
|
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
|
||||||
)
|
)
|
||||||
if c.Tokens.DefaultExpiry.Duration <= 0 {
|
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"))
|
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...)
|
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.
|
// ServiceExpiry returns the configured service token expiry duration.
|
||||||
func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.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.
|
// TestTrustedProxyValidation verifies that trusted_proxy must be a valid IP.
|
||||||
func TestTrustedProxyValidation(t *testing.T) {
|
func TestTrustedProxyValidation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
proxy string
|
proxy string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{"empty is valid (disabled)", "", false},
|
{"empty is valid (disabled)", "", false},
|
||||||
{"valid IPv4", "127.0.0.1", false},
|
{"valid IPv4", "127.0.0.1", false},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// openTestDB opens an in-memory SQLite database for testing.
|
// openTestDB opens an in-memory SQLite database for testing.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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.
|
// 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
|
// LatestSchemaVersion is the highest migration version defined in the
|
||||||
// migrations/ directory. Update this constant whenever a new migration file
|
// migrations/ directory. Update this constant whenever a new migration file
|
||||||
// is added.
|
// is added.
|
||||||
const LatestSchemaVersion = 7
|
const LatestSchemaVersion = 9
|
||||||
|
|
||||||
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
|
// 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
|
// 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"
|
"fmt"
|
||||||
"time"
|
"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
|
// ListCredentialedAccountIDs returns the set of account IDs that already have
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"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.
|
// policyRuleCols is the column list for all policy rule SELECT queries.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateAndGetPolicyRule(t *testing.T) {
|
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 (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetAccountTags_Empty(t *testing.T) {
|
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/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
"git.wntrmute.dev/mc/mcias/internal/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
type accountServiceServer struct {
|
type accountServiceServer struct {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"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 {
|
type adminServiceServer struct {
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
type authServiceServer struct {
|
type authServiceServer struct {
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type credentialServiceServer struct {
|
type credentialServiceServer struct {
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ import (
|
|||||||
"google.golang.org/grpc/peer"
|
"google.golang.org/grpc/peer"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// contextKey is the unexported context key type for this package.
|
// contextKey is the unexported context key type for this package.
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/grpc/test/bufconn"
|
"google.golang.org/grpc/test/bufconn"
|
||||||
|
|
||||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type policyServiceServer struct {
|
type policyServiceServer struct {
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
type tokenServiceServer struct {
|
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"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// contextKey is the unexported type for context keys in this package, preventing
|
// contextKey is the unexported type for context keys in this package, preventing
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||||
@@ -361,8 +361,8 @@ func TestClientIP(t *testing.T) {
|
|||||||
remoteAddr string
|
remoteAddr string
|
||||||
xForwardedFor string
|
xForwardedFor string
|
||||||
xRealIP string
|
xRealIP string
|
||||||
trustedProxy net.IP
|
|
||||||
want string
|
want string
|
||||||
|
trustedProxy net.IP
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no proxy configured: uses RemoteAddr",
|
name: "no proxy configured: uses RemoteAddr",
|
||||||
@@ -377,11 +377,11 @@ func TestClientIP(t *testing.T) {
|
|||||||
want: "198.51.100.9",
|
want: "198.51.100.9",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
|
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
|
||||||
remoteAddr: "10.0.0.1:8080",
|
remoteAddr: "10.0.0.1:8080",
|
||||||
xRealIP: "203.0.113.42",
|
xRealIP: "203.0.113.42",
|
||||||
trustedProxy: proxy,
|
trustedProxy: proxy,
|
||||||
want: "203.0.113.42",
|
want: "203.0.113.42",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "request from trusted proxy with X-Forwarded-For: uses first entry",
|
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",
|
want: "203.0.113.55",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "proxy request with no forwarding headers falls back to RemoteAddr host",
|
name: "proxy request with no forwarding headers falls back to RemoteAddr host",
|
||||||
remoteAddr: "10.0.0.1:8080",
|
remoteAddr: "10.0.0.1:8080",
|
||||||
trustedProxy: proxy,
|
trustedProxy: proxy,
|
||||||
want: "10.0.0.1",
|
want: "10.0.0.1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Security: attacker fakes X-Forwarded-For but connects directly.
|
// Security: attacker fakes X-Forwarded-For but connects directly.
|
||||||
|
|||||||
@@ -213,6 +213,11 @@ const (
|
|||||||
|
|
||||||
EventTokenDelegateGranted = "token_delegate_granted"
|
EventTokenDelegateGranted = "token_delegate_granted"
|
||||||
EventTokenDelegateRevoked = "token_delegate_revoked"
|
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
|
// ServiceAccountDelegate records that a specific account has been granted
|
||||||
@@ -229,6 +234,26 @@ type ServiceAccountDelegate struct {
|
|||||||
GranteeID int64 `json:"-"`
|
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.
|
// PolicyRuleRecord is the database representation of a policy rule.
|
||||||
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
||||||
// The ID, Priority, and Description are stored as dedicated columns.
|
// The ID, Priority, and Description are stored as dedicated columns.
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ var defaultRules = []Rule{
|
|||||||
OwnerMatchesSubject: true,
|
OwnerMatchesSubject: true,
|
||||||
Effect: Allow,
|
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
|
// Public endpoints: token validation and login do not require
|
||||||
// authentication. The middleware exempts them from RequireAuth entirely;
|
// authentication. The middleware exempts them from RequireAuth entirely;
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ const (
|
|||||||
|
|
||||||
ActionListRules Action = "policy:list"
|
ActionListRules Action = "policy:list"
|
||||||
ActionManageRules Action = "policy:manage"
|
ActionManageRules Action = "policy:manage"
|
||||||
|
|
||||||
|
ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service
|
||||||
|
ActionRemoveWebAuthn Action = "webauthn:remove" // admin
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResourceType identifies what kind of object a request targets.
|
// ResourceType identifies what kind of object a request targets.
|
||||||
@@ -60,6 +63,7 @@ const (
|
|||||||
ResourceAuditLog ResourceType = "audit_log"
|
ResourceAuditLog ResourceType = "audit_log"
|
||||||
ResourceTOTP ResourceType = "totp"
|
ResourceTOTP ResourceType = "totp"
|
||||||
ResourcePolicy ResourceType = "policy"
|
ResourcePolicy ResourceType = "policy"
|
||||||
|
ResourceWebAuthn ResourceType = "webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Effect is the outcome of policy evaluation.
|
// Effect is the outcome of policy evaluation.
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
"git.wntrmute.dev/mc/mcias/internal/middleware"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- Tag endpoints ----
|
// ---- 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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
"git.wntrmute.dev/mc/mcias/internal/middleware"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/ui"
|
"git.wntrmute.dev/mc/mcias/internal/ui"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
"git.wntrmute.dev/mc/mcias/internal/validate"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||||
"git.wntrmute.dev/kyle/mcias/web"
|
"git.wntrmute.dev/mc/mcias/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server holds the dependencies injected into all handlers.
|
// Server holds the dependencies injected into all handlers.
|
||||||
@@ -232,6 +232,14 @@ func (s *Server) Handler() http.Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
|
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.
|
// Security (DEF-09): apply defensive HTTP headers to the docs handlers.
|
||||||
// The Swagger UI page at /docs loads JavaScript from the same origin
|
// The Swagger UI page at /docs loads JavaScript from the same origin
|
||||||
// and renders untrusted content (API descriptions), so it benefits from
|
// 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.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(specYAML)
|
_, _ = 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).
|
// Vault endpoints (exempt from sealed middleware and auth).
|
||||||
unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
|
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/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
|
||||||
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm)))
|
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).
|
// Policy-gated endpoints (formerly admin-only; now controlled by the engine).
|
||||||
mux.Handle("DELETE /v1/auth/totp",
|
mux.Handle("DELETE /v1/auth/totp",
|
||||||
requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove)))
|
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)))
|
requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds)))
|
||||||
mux.Handle("PUT /v1/accounts/{id}/pgcreds",
|
mux.Handle("PUT /v1/accounts/{id}/pgcreds",
|
||||||
requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds)))
|
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",
|
mux.Handle("GET /v1/audit",
|
||||||
requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit)))
|
requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit)))
|
||||||
mux.Handle("GET /v1/accounts/{id}/tags",
|
mux.Handle("GET /v1/accounts/{id}/tags",
|
||||||
@@ -406,6 +436,12 @@ type loginRequest struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
TOTPCode string `json:"totp_code,omitempty"`
|
TOTPCode string `json:"totp_code,omitempty"`
|
||||||
|
// ServiceName and Tags identify the calling service. MCIAS evaluates the
|
||||||
|
// auth:login policy with these as the resource context, enabling operators
|
||||||
|
// to restrict which roles/account-types may log into specific services.
|
||||||
|
// Clients populate these from their [mcias] config section.
|
||||||
|
ServiceName string `json:"service_name,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginResponse is the response body for a successful login.
|
// loginResponse is the response body for a successful login.
|
||||||
@@ -516,13 +552,42 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Login succeeded: clear any outstanding failure counter.
|
// Login succeeded: clear any outstanding failure counter.
|
||||||
_ = s.db.ClearLoginFailures(acct.ID)
|
_ = s.db.ClearLoginFailures(acct.ID)
|
||||||
|
|
||||||
// Determine expiry.
|
// Load roles for expiry decision and policy check.
|
||||||
expiry := s.cfg.DefaultExpiry()
|
|
||||||
roles, err := s.db.GetRoles(acct.ID)
|
roles, err := s.db.GetRoles(acct.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Policy check: evaluate auth:login with the calling service's context.
|
||||||
|
// Operator rules can deny login based on role, account type, service name,
|
||||||
|
// or tags. The built-in default Allow for auth:login is overridden by any
|
||||||
|
// matching Deny rule (deny-wins semantics).
|
||||||
|
//
|
||||||
|
// Security: policy is checked after credential verification so that a
|
||||||
|
// policy-denied login returns 403 (not 401), distinguishing a service
|
||||||
|
// access restriction from a wrong password without leaking user existence.
|
||||||
|
{
|
||||||
|
input := policy.PolicyInput{
|
||||||
|
Subject: acct.UUID,
|
||||||
|
AccountType: string(acct.AccountType),
|
||||||
|
Roles: roles,
|
||||||
|
Action: policy.ActionLogin,
|
||||||
|
Resource: policy.Resource{
|
||||||
|
ServiceName: req.ServiceName,
|
||||||
|
Tags: req.Tags,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
|
||||||
|
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil,
|
||||||
|
audit.JSON("reason", "policy_deny", "service_name", req.ServiceName))
|
||||||
|
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine expiry.
|
||||||
|
expiry := s.cfg.DefaultExpiry()
|
||||||
for _, r := range roles {
|
for _, r := range roles {
|
||||||
if r == "admin" {
|
if r == "admin" {
|
||||||
expiry = s.cfg.AdminExpiry()
|
expiry = s.cfg.AdminExpiry()
|
||||||
@@ -639,11 +704,12 @@ type validateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type validateResponse struct {
|
type validateResponse struct {
|
||||||
Subject string `json:"sub,omitempty"`
|
Subject string `json:"sub,omitempty"`
|
||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at,omitempty"`
|
AccountType string `json:"account_type,omitempty"`
|
||||||
Roles []string `json:"roles,omitempty"`
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
Valid bool `json:"valid"`
|
Roles []string `json:"roles,omitempty"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if acct, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||||
resp.Username = acct.Username
|
resp.Username = acct.Username
|
||||||
|
resp.AccountType = string(acct.AccountType)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/mc/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// generateTOTPCode computes a valid RFC 6238 TOTP code for the current time
|
// generateTOTPCode computes a valid RFC 6238 TOTP code for the current time
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ package server
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
"git.wntrmute.dev/mc/mcias/internal/middleware"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// unsealRequest is the request body for POST /v1/vault/unseal.
|
// unsealRequest is the request body for POST /v1/vault/unseal.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHandleHealthSealed(t *testing.T) {
|
func TestHandleHealthSealed(t *testing.T) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"context"
|
"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
|
// uiContextKey is the unexported type for UI context values, preventing
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection.
|
// 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
|
// on the next unseal. This is safe because sealed middleware prevents
|
||||||
// reaching CSRF-protected routes.
|
// reaching CSRF-protected routes.
|
||||||
type CSRFManager struct {
|
type CSRFManager struct {
|
||||||
mu sync.Mutex
|
|
||||||
key []byte
|
|
||||||
vault *vault.Vault
|
vault *vault.Vault
|
||||||
}
|
key []byte
|
||||||
|
mu sync.Mutex
|
||||||
// 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)}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily
|
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
"git.wntrmute.dev/mc/mcias/internal/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.
|
// 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{
|
u.render(w, "account_detail", AccountDetailData{
|
||||||
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
|
||||||
Account: acct,
|
Account: acct,
|
||||||
@@ -211,6 +220,9 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
TokenDelegates: tokenDelegates,
|
TokenDelegates: tokenDelegates,
|
||||||
DelegatableAccounts: delegatableAccounts,
|
DelegatableAccounts: delegatableAccounts,
|
||||||
CanIssueToken: true, // account_detail is admin-only, so admin can always issue
|
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"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const auditPageSize = 50
|
const auditPageSize = 50
|
||||||
|
|||||||
@@ -3,17 +3,19 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/mc/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
"git.wntrmute.dev/mc/mcias/internal/crypto"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
"git.wntrmute.dev/mc/mcias/internal/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleLoginPage renders the login form.
|
// handleLoginPage renders the login form.
|
||||||
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
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).
|
// 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.
|
// handleProfilePage renders the profile page for the currently logged-in user.
|
||||||
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
|
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
|
||||||
csrfToken, _ := u.setCSRFCookies(w)
|
csrfToken, _ := u.setCSRFCookies(w)
|
||||||
u.render(w, "profile", ProfileData{
|
claims := claimsFromContext(r.Context())
|
||||||
|
|
||||||
|
data := ProfileData{
|
||||||
PageData: PageData{
|
PageData: PageData{
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
ActorName: u.actorName(r),
|
ActorName: u.actorName(r),
|
||||||
IsAdmin: isAdmin(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
|
// handleSelfChangePassword allows an authenticated human user to change their
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleDashboard renders the main dashboard page. Admin users see account
|
// handleDashboard renders the main dashboard page. Admin users see account
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/mc/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
"git.wntrmute.dev/mc/mcias/internal/policy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- Policies page ----
|
// ---- Policies page ----
|
||||||
@@ -129,46 +129,69 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
|
|||||||
priority = p
|
priority = p
|
||||||
}
|
}
|
||||||
|
|
||||||
effectStr := r.FormValue("effect")
|
var ruleJSON []byte
|
||||||
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
|
|
||||||
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body := policy.RuleBody{
|
if rawJSON := strings.TrimSpace(r.FormValue("rule_json")); rawJSON != "" {
|
||||||
Effect: policy.Effect(effectStr),
|
// 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 {
|
||||||
// Multi-value fields.
|
u.renderError(w, r, http.StatusBadRequest, fmt.Sprintf("invalid rule JSON: %v", err))
|
||||||
if roles := r.Form["roles"]; len(roles) > 0 {
|
return
|
||||||
body.Roles = roles
|
}
|
||||||
}
|
if body.Effect != policy.Allow && body.Effect != policy.Deny {
|
||||||
if types := r.Form["account_types"]; len(types) > 0 {
|
u.renderError(w, r, http.StatusBadRequest, "rule JSON must include effect 'allow' or 'deny'")
|
||||||
body.AccountTypes = types
|
return
|
||||||
}
|
}
|
||||||
if actions := r.Form["actions"]; len(actions) > 0 {
|
var err error
|
||||||
acts := make([]policy.Action, len(actions))
|
ruleJSON, err = json.Marshal(body)
|
||||||
for i, a := range actions {
|
if err != nil {
|
||||||
acts[i] = policy.Action(a)
|
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)
|
body := policy.RuleBody{
|
||||||
if err != nil {
|
Effect: policy.Effect(effectStr),
|
||||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
}
|
||||||
return
|
|
||||||
|
// 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.
|
// 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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
"git.wntrmute.dev/mc/mcias/internal/audit"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
"git.wntrmute.dev/mc/mcias/internal/middleware"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/mc/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
"git.wntrmute.dev/mc/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UnsealData is the view model for the unseal page.
|
// UnsealData is the view model for the unseal page.
|
||||||
|
|||||||
696
internal/ui/handlers_webauthn.go
Normal file
696
internal/ui/handlers_webauthn.go
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
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/model"
|
||||||
|
"git.wntrmute.dev/mc/mcias/internal/token"
|
||||||
|
mciaswebauthn "git.wntrmute.dev/mc/mcias/internal/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
webauthnCeremonyTTL = 120 * time.Second
|
||||||
|
webauthnCleanupPeriod = 5 * time.Minute
|
||||||
|
webauthnNonceBytes = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// webauthnCeremony holds a pending WebAuthn ceremony.
|
||||||
|
type webauthnCeremony struct {
|
||||||
|
expiresAt time.Time
|
||||||
|
session *libwebauthn.SessionData
|
||||||
|
accountID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingWebAuthnCeremonies stores in-flight WebAuthn ceremonies for the UI.
|
||||||
|
var pendingUIWebAuthnCeremonies sync.Map //nolint:gochecknoglobals
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
go cleanupUIWebAuthnCeremonies()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupUIWebAuthnCeremonies() {
|
||||||
|
ticker := time.NewTicker(webauthnCleanupPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now()
|
||||||
|
pendingUIWebAuthnCeremonies.Range(func(key, value any) bool {
|
||||||
|
c, ok := value.(*webauthnCeremony)
|
||||||
|
if !ok || now.After(c.expiresAt) {
|
||||||
|
pendingUIWebAuthnCeremonies.Delete(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeUICeremony(session *libwebauthn.SessionData, accountID int64) (string, error) {
|
||||||
|
raw, err := crypto.RandomBytes(webauthnNonceBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err)
|
||||||
|
}
|
||||||
|
nonce := fmt.Sprintf("%x", raw)
|
||||||
|
pendingUIWebAuthnCeremonies.Store(nonce, &webauthnCeremony{
|
||||||
|
session: session,
|
||||||
|
accountID: accountID,
|
||||||
|
expiresAt: time.Now().Add(webauthnCeremonyTTL),
|
||||||
|
})
|
||||||
|
return nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeUICeremony(nonce string) (*webauthnCeremony, bool) {
|
||||||
|
v, ok := pendingUIWebAuthnCeremonies.LoadAndDelete(nonce)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
c, ok2 := v.(*webauthnCeremony)
|
||||||
|
if !ok2 || time.Now().After(c.expiresAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Profile: registration ----
|
||||||
|
|
||||||
|
// handleWebAuthnBegin starts a WebAuthn credential registration ceremony.
|
||||||
|
func (u *UIServer) handleWebAuthnBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !u.cfg.WebAuthnEnabled() {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "WebAuthn not configured")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Password == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "password is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: check lockout.
|
||||||
|
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||||
|
if lockErr != nil {
|
||||||
|
u.logger.Error("lockout check (WebAuthn enroll)", "error", lockErr)
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
writeJSONError(w, http.StatusTooManyRequests, "account temporarily locked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: verify current password.
|
||||||
|
ok, verifyErr := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
||||||
|
if verifyErr != nil || !ok {
|
||||||
|
_ = u.db.RecordLoginFailure(acct.ID)
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "password is incorrect")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
masterKey, err := u.vault.MasterKey()
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dbCreds, err := u.db.GetWebAuthnCredentials(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("create webauthn instance", "error", err)
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "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 {
|
||||||
|
u.logger.Error("begin webauthn registration", "error", err)
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, err := storeUICeremony(session, acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsJSON, _ := json.Marshal(creation)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"options": json.RawMessage(optionsJSON),
|
||||||
|
"nonce": nonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebAuthnFinish completes WebAuthn credential registration.
|
||||||
|
func (u *UIServer) handleWebAuthnFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !u.cfg.WebAuthnEnabled() {
|
||||||
|
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := claimsFromContext(r.Context())
|
||||||
|
if claims == nil {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acct, err := u.db.GetAccountByUUID(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := buf.ReadFrom(r.Body); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrapper struct {
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Credential json.RawMessage `json:"credential"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &wrapper); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ceremony, ok := consumeUICeremony(wrapper.Nonce)
|
||||||
|
if !ok {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "ceremony expired or invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ceremony.accountID != acct.ID {
|
||||||
|
writeJSONError(w, http.StatusForbidden, "ceremony mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
masterKey, err := u.vault.MasterKey()
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dbCreds, err := u.db.GetWebAuthnCredentials(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
libCreds, err := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||||
|
fakeReq, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
|
||||||
|
fakeReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
cred, err := wa.FinishRegistration(user, *ceremony.session, fakeReq)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("finish webauthn registration", "error", err)
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "registration failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
discoverable := cred.Flags.UserVerified && cred.Flags.BackupEligible
|
||||||
|
name := wrapper.Name
|
||||||
|
if name == "" {
|
||||||
|
name = "Passkey"
|
||||||
|
}
|
||||||
|
|
||||||
|
modelCred, err := mciaswebauthn.EncryptCredential(masterKey, cred, name, discoverable)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelCred.AccountID = acct.ID
|
||||||
|
|
||||||
|
credID, err := u.db.CreateWebAuthnCredential(modelCred)
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("store webauthn credential", "error", err)
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.writeAudit(r, model.EventWebAuthnEnrolled, &acct.ID, &acct.ID,
|
||||||
|
audit.JSON("credential_id", fmt.Sprintf("%d", credID), "name", name))
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"id": credID,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebAuthnDelete removes a WebAuthn credential from the profile page.
|
||||||
|
func (u *UIServer) handleWebAuthnDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
credIDStr := r.PathValue("id")
|
||||||
|
credID, err := strconv.ParseInt(credIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid credential ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.DeleteWebAuthnCredential(credID, acct.ID); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "credential not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.writeAudit(r, model.EventWebAuthnRemoved, &acct.ID, &acct.ID,
|
||||||
|
audit.JSON("credential_id", credIDStr))
|
||||||
|
|
||||||
|
// Return updated credentials list fragment.
|
||||||
|
creds, _ := u.db.GetWebAuthnCredentials(acct.ID)
|
||||||
|
csrfToken, _ := u.setCSRFCookies(w)
|
||||||
|
u.render(w, "webauthn_credentials", ProfileData{
|
||||||
|
PageData: PageData{
|
||||||
|
CSRFToken: csrfToken,
|
||||||
|
ActorName: u.actorName(r),
|
||||||
|
IsAdmin: isAdmin(r),
|
||||||
|
},
|
||||||
|
WebAuthnCreds: creds,
|
||||||
|
DeletePrefix: "/profile/webauthn",
|
||||||
|
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Login: WebAuthn ----
|
||||||
|
|
||||||
|
// handleWebAuthnLoginBegin starts a WebAuthn login ceremony from the UI.
|
||||||
|
func (u *UIServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !u.cfg.WebAuthnEnabled() {
|
||||||
|
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
assertion *protocol.CredentialAssertion
|
||||||
|
session *libwebauthn.SessionData
|
||||||
|
accountID int64
|
||||||
|
)
|
||||||
|
|
||||||
|
if req.Username != "" {
|
||||||
|
acct, lookupErr := u.db.GetAccountByUsername(req.Username)
|
||||||
|
if lookupErr != nil || acct.Status != model.AccountStatusActive {
|
||||||
|
// Security: return discoverable login as dummy for unknown users.
|
||||||
|
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||||
|
} else {
|
||||||
|
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||||
|
if lockErr != nil {
|
||||||
|
u.logger.Error("lockout check (WebAuthn UI login)", "error", lockErr)
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||||
|
} else {
|
||||||
|
masterKey, mkErr := u.vault.MasterKey()
|
||||||
|
if mkErr != nil {
|
||||||
|
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbCreds, dbErr := u.db.GetWebAuthnCredentials(acct.ID)
|
||||||
|
if dbErr != nil || len(dbCreds) == 0 {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "no passkeys registered")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||||
|
if decErr != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||||
|
assertion, session, err = wa.BeginLogin(user)
|
||||||
|
accountID = acct.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assertion, session, err = wa.BeginDiscoverableLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error("begin webauthn login", "error", err)
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, err := storeUICeremony(session, accountID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsJSON, _ := json.Marshal(assertion)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"options": json.RawMessage(optionsJSON),
|
||||||
|
"nonce": nonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebAuthnLoginFinish completes a WebAuthn login from the UI.
|
||||||
|
func (u *UIServer) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !u.cfg.WebAuthnEnabled() {
|
||||||
|
writeJSONError(w, http.StatusNotFound, "WebAuthn not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := buf.ReadFrom(r.Body); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrapper struct {
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Credential json.RawMessage `json:"credential"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &wrapper); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ceremony, ok := consumeUICeremony(wrapper.Nonce)
|
||||||
|
if !ok {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := mciaswebauthn.NewWebAuthn(&u.cfg.WebAuthn)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
masterKey, err := u.vault.MasterKey()
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeReq, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(wrapper.Credential))
|
||||||
|
fakeReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
var (
|
||||||
|
acct *model.Account
|
||||||
|
cred *libwebauthn.Credential
|
||||||
|
dbCreds []*model.WebAuthnCredential
|
||||||
|
)
|
||||||
|
|
||||||
|
if ceremony.accountID != 0 {
|
||||||
|
acct, err = u.db.GetAccountByID(ceremony.accountID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbCreds, err = u.db.GetWebAuthnCredentials(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
libCreds, decErr := mciaswebauthn.DecryptCredentials(masterKey, dbCreds)
|
||||||
|
if decErr != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := mciaswebauthn.NewAccountUser([]byte(acct.UUID), acct.Username, libCreds)
|
||||||
|
cred, err = wa.FinishLogin(user, *ceremony.session, fakeReq)
|
||||||
|
if err != nil {
|
||||||
|
u.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil, `{"reason":"assertion_failed"}`)
|
||||||
|
_ = u.db.RecordLoginFailure(acct.ID)
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handler := func(rawID, userHandle []byte) (libwebauthn.User, error) {
|
||||||
|
acctUUID := string(userHandle)
|
||||||
|
foundAcct, lookupErr := u.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 := u.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 {
|
||||||
|
u.writeAudit(r, model.EventWebAuthnLoginFail, nil, nil, `{"reason":"discoverable_assertion_failed"}`)
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if acct == nil {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if acct.Status != model.AccountStatusActive {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, lockErr := u.db.IsLockedOut(acct.ID)
|
||||||
|
if lockErr != nil {
|
||||||
|
u.logger.Error("lockout check (WebAuthn UI login finish)", "error", lockErr)
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sign counter.
|
||||||
|
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 {
|
||||||
|
if cred.Authenticator.SignCount > 0 || matchedDBCred.SignCount > 0 {
|
||||||
|
if cred.Authenticator.SignCount <= matchedDBCred.SignCount {
|
||||||
|
u.writeAudit(r, model.EventWebAuthnLoginFail, &acct.ID, nil,
|
||||||
|
audit.JSON("reason", "counter_rollback"))
|
||||||
|
_ = u.db.RecordLoginFailure(acct.ID)
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = u.db.UpdateWebAuthnSignCount(matchedDBCred.ID, cred.Authenticator.SignCount)
|
||||||
|
_ = u.db.UpdateWebAuthnLastUsed(matchedDBCred.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = u.db.ClearLoginFailures(acct.ID)
|
||||||
|
|
||||||
|
// Issue JWT and set session cookie.
|
||||||
|
expiry := u.cfg.DefaultExpiry()
|
||||||
|
roles, err := u.db.GetRoles(acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, rol := range roles {
|
||||||
|
if rol == "admin" {
|
||||||
|
expiry = u.cfg.AdminExpiry()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
privKey, err := u.vault.PrivKey()
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusServiceUnavailable, "vault sealed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenStr, tokenClaims, err := token.IssueToken(privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.TrackToken(tokenClaims.JTI, acct.ID, tokenClaims.IssuedAt, tokenClaims.ExpiresAt); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: tokenStr,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Expires: tokenClaims.ExpiresAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := u.setCSRFCookies(w); err != nil {
|
||||||
|
u.logger.Error("set CSRF cookie", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "")
|
||||||
|
u.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
|
||||||
|
audit.JSON("jti", tokenClaims.JTI, "via", "webauthn_ui"))
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"redirect": "/dashboard"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Admin: WebAuthn credential management ----
|
||||||
|
|
||||||
|
// handleAdminWebAuthnDelete removes a WebAuthn credential from the admin account detail page.
|
||||||
|
func (u *UIServer) handleAdminWebAuthnDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountUUID := r.PathValue("id")
|
||||||
|
acct, err := u.db.GetAccountByUUID(accountUUID)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credIDStr := r.PathValue("credentialId")
|
||||||
|
credID, err := strconv.ParseInt(credIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
u.renderError(w, r, http.StatusBadRequest, "invalid credential ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.db.DeleteWebAuthnCredentialAdmin(credID); err != nil {
|
||||||
|
u.renderError(w, r, http.StatusNotFound, "credential not found")
|
||||||
|
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.EventWebAuthnRemoved, actorID, &acct.ID,
|
||||||
|
audit.JSON("credential_id", credIDStr, "admin", "true"))
|
||||||
|
|
||||||
|
// Return updated credentials list.
|
||||||
|
creds, _ := u.db.GetWebAuthnCredentials(acct.ID)
|
||||||
|
csrfToken, _ := u.setCSRFCookies(w)
|
||||||
|
u.render(w, "webauthn_credentials", struct { //nolint:govet // fieldalignment: anonymous struct
|
||||||
|
PageData
|
||||||
|
WebAuthnCreds []*model.WebAuthnCredential
|
||||||
|
DeletePrefix string
|
||||||
|
WebAuthnEnabled bool
|
||||||
|
}{
|
||||||
|
PageData: PageData{
|
||||||
|
CSRFToken: csrfToken,
|
||||||
|
ActorName: u.actorName(r),
|
||||||
|
IsAdmin: isAdmin(r),
|
||||||
|
},
|
||||||
|
WebAuthnCreds: creds,
|
||||||
|
DeletePrefix: "/accounts/" + accountUUID + "/webauthn",
|
||||||
|
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSONError writes a JSON error response.
|
||||||
|
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user