diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9d4cbb0..bb6e8a8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -128,7 +128,8 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest. **Human accounts** — interactive users. Can authenticate via: - Username + password (Argon2id hash stored in DB) - Optional TOTP (RFC 6238); if enrolled, required on every login -- Future: FIDO2/WebAuthn, Yubikey (not in scope for v1) +- Optional FIDO2/WebAuthn passkeys and security keys; discoverable credentials + enable passwordless login, non-discoverable credentials serve as 2FA **System accounts** — non-interactive service identities. Have: - A single active bearer token at a time (rotating the token revokes the old one) @@ -420,6 +421,17 @@ value in an HTMX fragment or flash message. | POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code | | DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) | +### WebAuthn Endpoints + +| Method | Path | Auth required | Description | +|---|---|---|---| +| POST | `/v1/auth/webauthn/register/begin` | bearer JWT | Begin WebAuthn registration (requires password re-auth) | +| POST | `/v1/auth/webauthn/register/finish` | bearer JWT | Complete WebAuthn registration | +| POST | `/v1/auth/webauthn/login/begin` | none | Begin WebAuthn login (discoverable or username-scoped) | +| POST | `/v1/auth/webauthn/login/finish` | none | Complete WebAuthn login, returns JWT | +| GET | `/v1/accounts/{id}/webauthn` | admin JWT | List WebAuthn credential metadata | +| DELETE | `/v1/accounts/{id}/webauthn/{credentialId}` | admin JWT | Remove WebAuthn credential | + ### Postgres Credential Endpoints | Method | Path | Auth required | Description | @@ -697,14 +709,36 @@ 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 - Passwords are stored as PHC-format Argon2id strings (e.g., `$argon2id$v=19$m=65536,t=3,p=4$$`), embedding algorithm parameters. Future parameter upgrades are transparent. -- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a - master key held only in server memory (derived at startup from a passphrase - or keyfile). The nonce is stored adjacent to the ciphertext. +- TOTP secrets, Postgres passwords, and WebAuthn credential IDs/public keys are + encrypted with AES-256-GCM using a master key held only in server memory + (derived at startup from a passphrase or keyfile). The nonce is stored + adjacent to the ciphertext. - The master key salt is stored in `server_config.master_key_salt` so the Argon2id KDF produces the same key on every restart. Generated on first run. - The signing key encryption is layered: the Ed25519 private key is wrapped @@ -782,7 +816,7 @@ mcias/ │ ├── config/ # config file parsing and validation │ ├── crypto/ # key management, AES-GCM helpers, master key derivation │ ├── db/ # SQLite access layer (schema, migrations, queries) -│ │ └── migrations/ # numbered SQL migrations (currently 8) +│ │ └── migrations/ # numbered SQL migrations (currently 9) │ ├── grpcserver/ # gRPC handler implementations │ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy) │ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.) @@ -791,7 +825,8 @@ mcias/ │ ├── token/ # JWT issuance, validation, revocation │ ├── ui/ # web UI context, CSRF, session, template handlers │ ├── validate/ # input validation helpers (username, password strength) -│ └── vault/ # master key lifecycle: seal/unseal state, key derivation +│ ├── vault/ # master key lifecycle: seal/unseal state, key derivation +│ └── webauthn/ # FIDO2/WebAuthn adapter (encrypt/decrypt credentials, user interface) ├── web/ │ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build) │ ├── templates/ # HTML templates (base layout, pages, HTMX fragments) @@ -1830,3 +1865,66 @@ the "Token Issue Access" section. | `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. diff --git a/PROGRESS.md b/PROGRESS.md index 4b75358..972e6c0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,29 @@ 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 — 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) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 8f695af..a48a3ca 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -744,6 +744,79 @@ See ARCHITECTURE.md §21 (Token Issuance Delegation) for design details. --- +## 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 ``` @@ -760,7 +833,9 @@ Phase 0 → Phase 1 (1.1, 1.2, 1.3, 1.4 in parallel or sequence) → 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. -All phases complete as of v1.0.0 (2026-03-15). +Phases 0–13 complete as of v1.0.0 (2026-03-15). +Phase 14 complete as of 2026-03-16. diff --git a/cmd/mciasdb/account.go b/cmd/mciasdb/account.go index da661ca..3d4e24f 100644 --- a/cmd/mciasdb/account.go +++ b/cmd/mciasdb/account.go @@ -13,7 +13,7 @@ import ( func (t *tool) runAccount(args []string) { if len(args) == 0 { - fatalf("account requires a subcommand: list, get, create, set-password, set-status, reset-totp") + fatalf("account requires a subcommand: list, get, create, set-password, set-status, reset-totp, reset-webauthn") } switch args[0] { case "list": @@ -28,6 +28,8 @@ func (t *tool) runAccount(args []string) { t.accountSetStatus(args[1:]) case "reset-totp": t.accountResetTOTP(args[1:]) + case "reset-webauthn": + t.webauthnReset(args[1:]) default: fatalf("unknown account subcommand %q", args[0]) } diff --git a/cmd/mciasdb/main.go b/cmd/mciasdb/main.go index cb798de..39bdc20 100644 --- a/cmd/mciasdb/main.go +++ b/cmd/mciasdb/main.go @@ -109,6 +109,8 @@ func main() { tool.runAudit(subArgs) case "pgcreds": tool.runPGCreds(subArgs) + case "webauthn": + tool.runWebAuthn(subArgs) case "rekey": tool.runRekey(subArgs) default: @@ -245,6 +247,11 @@ Commands: account set-password --id UUID (prompts interactively) account set-status --id UUID --status active|inactive|deleted account reset-totp --id UUID + account reset-webauthn --id UUID + + webauthn list --id UUID + webauthn delete --id UUID --credential-id N + webauthn reset --id UUID role list --id UUID role grant --id UUID --role ROLE diff --git a/cmd/mciasdb/webauthn.go b/cmd/mciasdb/webauthn.go new file mode 100644 index 0000000..b12d45b --- /dev/null +++ b/cmd/mciasdb/webauthn.go @@ -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) +} diff --git a/gen/mcias/v1/account.pb.go b/gen/mcias/v1/account.pb.go index d7b75ae..0bc85bf 100644 --- a/gen/mcias/v1/account.pb.go +++ b/gen/mcias/v1/account.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.4 +// protoc v3.20.3 // source: mcias/v1/account.proto package mciasv1 diff --git a/gen/mcias/v1/account_grpc.pb.go b/gen/mcias/v1/account_grpc.pb.go index 4311335..246916e 100644 --- a/gen/mcias/v1/account_grpc.pb.go +++ b/gen/mcias/v1/account_grpc.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v6.33.4 +// - protoc v3.20.3 // source: mcias/v1/account.proto package mciasv1 diff --git a/gen/mcias/v1/auth.pb.go b/gen/mcias/v1/auth.pb.go index 4a99314..e26fa5c 100644 --- a/gen/mcias/v1/auth.pb.go +++ b/gen/mcias/v1/auth.pb.go @@ -569,6 +569,288 @@ func (*RemoveTOTPResponse) Descriptor() ([]byte, []int) { return file_mcias_v1_auth_proto_rawDescGZIP(), []int{11} } +// ListWebAuthnCredentialsRequest lists metadata for an account's WebAuthn credentials. +type ListWebAuthnCredentialsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` // UUID + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListWebAuthnCredentialsRequest) Reset() { + *x = ListWebAuthnCredentialsRequest{} + mi := &file_mcias_v1_auth_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListWebAuthnCredentialsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListWebAuthnCredentialsRequest) ProtoMessage() {} + +func (x *ListWebAuthnCredentialsRequest) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_auth_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListWebAuthnCredentialsRequest.ProtoReflect.Descriptor instead. +func (*ListWebAuthnCredentialsRequest) Descriptor() ([]byte, []int) { + return file_mcias_v1_auth_proto_rawDescGZIP(), []int{12} +} + +func (x *ListWebAuthnCredentialsRequest) GetAccountId() string { + if x != nil { + return x.AccountId + } + return "" +} + +// WebAuthnCredentialInfo holds metadata about a stored WebAuthn credential. +// Credential material (IDs, public keys) is never included. +type WebAuthnCredentialInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Aaguid string `protobuf:"bytes,3,opt,name=aaguid,proto3" json:"aaguid,omitempty"` + SignCount uint32 `protobuf:"varint,4,opt,name=sign_count,json=signCount,proto3" json:"sign_count,omitempty"` + Discoverable bool `protobuf:"varint,5,opt,name=discoverable,proto3" json:"discoverable,omitempty"` + Transports string `protobuf:"bytes,6,opt,name=transports,proto3" json:"transports,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WebAuthnCredentialInfo) Reset() { + *x = WebAuthnCredentialInfo{} + mi := &file_mcias_v1_auth_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WebAuthnCredentialInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WebAuthnCredentialInfo) ProtoMessage() {} + +func (x *WebAuthnCredentialInfo) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_auth_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WebAuthnCredentialInfo.ProtoReflect.Descriptor instead. +func (*WebAuthnCredentialInfo) Descriptor() ([]byte, []int) { + return file_mcias_v1_auth_proto_rawDescGZIP(), []int{13} +} + +func (x *WebAuthnCredentialInfo) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *WebAuthnCredentialInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *WebAuthnCredentialInfo) GetAaguid() string { + if x != nil { + return x.Aaguid + } + return "" +} + +func (x *WebAuthnCredentialInfo) GetSignCount() uint32 { + if x != nil { + return x.SignCount + } + return 0 +} + +func (x *WebAuthnCredentialInfo) GetDiscoverable() bool { + if x != nil { + return x.Discoverable + } + return false +} + +func (x *WebAuthnCredentialInfo) GetTransports() string { + if x != nil { + return x.Transports + } + return "" +} + +func (x *WebAuthnCredentialInfo) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *WebAuthnCredentialInfo) GetLastUsedAt() *timestamppb.Timestamp { + if x != nil { + return x.LastUsedAt + } + return nil +} + +// ListWebAuthnCredentialsResponse returns credential metadata. +type ListWebAuthnCredentialsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Credentials []*WebAuthnCredentialInfo `protobuf:"bytes,1,rep,name=credentials,proto3" json:"credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListWebAuthnCredentialsResponse) Reset() { + *x = ListWebAuthnCredentialsResponse{} + mi := &file_mcias_v1_auth_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListWebAuthnCredentialsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListWebAuthnCredentialsResponse) ProtoMessage() {} + +func (x *ListWebAuthnCredentialsResponse) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_auth_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListWebAuthnCredentialsResponse.ProtoReflect.Descriptor instead. +func (*ListWebAuthnCredentialsResponse) Descriptor() ([]byte, []int) { + return file_mcias_v1_auth_proto_rawDescGZIP(), []int{14} +} + +func (x *ListWebAuthnCredentialsResponse) GetCredentials() []*WebAuthnCredentialInfo { + if x != nil { + return x.Credentials + } + return nil +} + +// RemoveWebAuthnCredentialRequest removes a specific WebAuthn credential (admin). +type RemoveWebAuthnCredentialRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` // UUID + CredentialId int64 `protobuf:"varint,2,opt,name=credential_id,json=credentialId,proto3" json:"credential_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveWebAuthnCredentialRequest) Reset() { + *x = RemoveWebAuthnCredentialRequest{} + mi := &file_mcias_v1_auth_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveWebAuthnCredentialRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveWebAuthnCredentialRequest) ProtoMessage() {} + +func (x *RemoveWebAuthnCredentialRequest) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_auth_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveWebAuthnCredentialRequest.ProtoReflect.Descriptor instead. +func (*RemoveWebAuthnCredentialRequest) Descriptor() ([]byte, []int) { + return file_mcias_v1_auth_proto_rawDescGZIP(), []int{15} +} + +func (x *RemoveWebAuthnCredentialRequest) GetAccountId() string { + if x != nil { + return x.AccountId + } + return "" +} + +func (x *RemoveWebAuthnCredentialRequest) GetCredentialId() int64 { + if x != nil { + return x.CredentialId + } + return 0 +} + +// RemoveWebAuthnCredentialResponse confirms removal. +type RemoveWebAuthnCredentialResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveWebAuthnCredentialResponse) Reset() { + *x = RemoveWebAuthnCredentialResponse{} + mi := &file_mcias_v1_auth_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveWebAuthnCredentialResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveWebAuthnCredentialResponse) ProtoMessage() {} + +func (x *RemoveWebAuthnCredentialResponse) ProtoReflect() protoreflect.Message { + mi := &file_mcias_v1_auth_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveWebAuthnCredentialResponse.ProtoReflect.Descriptor instead. +func (*RemoveWebAuthnCredentialResponse) Descriptor() ([]byte, []int) { + return file_mcias_v1_auth_proto_rawDescGZIP(), []int{16} +} + var File_mcias_v1_auth_proto protoreflect.FileDescriptor const file_mcias_v1_auth_proto_rawDesc = "" + @@ -601,7 +883,31 @@ const file_mcias_v1_auth_proto_rawDesc = "" + "\x11RemoveTOTPRequest\x12\x1d\n" + "\n" + "account_id\x18\x01 \x01(\tR\taccountId\"\x14\n" + - "\x12RemoveTOTPResponse2\xab\x03\n" + + "\x12RemoveTOTPResponse\"?\n" + + "\x1eListWebAuthnCredentialsRequest\x12\x1d\n" + + "\n" + + "account_id\x18\x01 \x01(\tR\taccountId\"\xb0\x02\n" + + "\x16WebAuthnCredentialInfo\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" + + "\x06aaguid\x18\x03 \x01(\tR\x06aaguid\x12\x1d\n" + + "\n" + + "sign_count\x18\x04 \x01(\rR\tsignCount\x12\"\n" + + "\fdiscoverable\x18\x05 \x01(\bR\fdiscoverable\x12\x1e\n" + + "\n" + + "transports\x18\x06 \x01(\tR\n" + + "transports\x129\n" + + "\n" + + "created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" + + "\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "lastUsedAt\"e\n" + + "\x1fListWebAuthnCredentialsResponse\x12B\n" + + "\vcredentials\x18\x01 \x03(\v2 .mcias.v1.WebAuthnCredentialInfoR\vcredentials\"e\n" + + "\x1fRemoveWebAuthnCredentialRequest\x12\x1d\n" + + "\n" + + "account_id\x18\x01 \x01(\tR\taccountId\x12#\n" + + "\rcredential_id\x18\x02 \x01(\x03R\fcredentialId\"\"\n" + + " RemoveWebAuthnCredentialResponse2\x8e\x05\n" + "\vAuthService\x128\n" + "\x05Login\x12\x16.mcias.v1.LoginRequest\x1a\x17.mcias.v1.LoginResponse\x12;\n" + "\x06Logout\x12\x17.mcias.v1.LogoutRequest\x1a\x18.mcias.v1.LogoutResponse\x12G\n" + @@ -611,7 +917,9 @@ const file_mcias_v1_auth_proto_rawDesc = "" + "EnrollTOTP\x12\x1b.mcias.v1.EnrollTOTPRequest\x1a\x1c.mcias.v1.EnrollTOTPResponse\x12J\n" + "\vConfirmTOTP\x12\x1c.mcias.v1.ConfirmTOTPRequest\x1a\x1d.mcias.v1.ConfirmTOTPResponse\x12G\n" + "\n" + - "RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3" + "RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponse\x12n\n" + + "\x17ListWebAuthnCredentials\x12(.mcias.v1.ListWebAuthnCredentialsRequest\x1a).mcias.v1.ListWebAuthnCredentialsResponse\x12q\n" + + "\x18RemoveWebAuthnCredential\x12).mcias.v1.RemoveWebAuthnCredentialRequest\x1a*.mcias.v1.RemoveWebAuthnCredentialResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3" var ( file_mcias_v1_auth_proto_rawDescOnce sync.Once @@ -625,42 +933,54 @@ func file_mcias_v1_auth_proto_rawDescGZIP() []byte { return file_mcias_v1_auth_proto_rawDescData } -var file_mcias_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_mcias_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_mcias_v1_auth_proto_goTypes = []any{ - (*LoginRequest)(nil), // 0: mcias.v1.LoginRequest - (*LoginResponse)(nil), // 1: mcias.v1.LoginResponse - (*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest - (*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse - (*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest - (*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse - (*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest - (*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse - (*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest - (*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse - (*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest - (*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse - (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp + (*LoginRequest)(nil), // 0: mcias.v1.LoginRequest + (*LoginResponse)(nil), // 1: mcias.v1.LoginResponse + (*LogoutRequest)(nil), // 2: mcias.v1.LogoutRequest + (*LogoutResponse)(nil), // 3: mcias.v1.LogoutResponse + (*RenewTokenRequest)(nil), // 4: mcias.v1.RenewTokenRequest + (*RenewTokenResponse)(nil), // 5: mcias.v1.RenewTokenResponse + (*EnrollTOTPRequest)(nil), // 6: mcias.v1.EnrollTOTPRequest + (*EnrollTOTPResponse)(nil), // 7: mcias.v1.EnrollTOTPResponse + (*ConfirmTOTPRequest)(nil), // 8: mcias.v1.ConfirmTOTPRequest + (*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse + (*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest + (*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse + (*ListWebAuthnCredentialsRequest)(nil), // 12: mcias.v1.ListWebAuthnCredentialsRequest + (*WebAuthnCredentialInfo)(nil), // 13: mcias.v1.WebAuthnCredentialInfo + (*ListWebAuthnCredentialsResponse)(nil), // 14: mcias.v1.ListWebAuthnCredentialsResponse + (*RemoveWebAuthnCredentialRequest)(nil), // 15: mcias.v1.RemoveWebAuthnCredentialRequest + (*RemoveWebAuthnCredentialResponse)(nil), // 16: mcias.v1.RemoveWebAuthnCredentialResponse + (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp } var file_mcias_v1_auth_proto_depIdxs = []int32{ - 12, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp - 12, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp - 0, // 2: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest - 2, // 3: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest - 4, // 4: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest - 6, // 5: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest - 8, // 6: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest - 10, // 7: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest - 1, // 8: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse - 3, // 9: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse - 5, // 10: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse - 7, // 11: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse - 9, // 12: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse - 11, // 13: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse - 8, // [8:14] is the sub-list for method output_type - 2, // [2:8] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 17, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp + 17, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp + 17, // 2: mcias.v1.WebAuthnCredentialInfo.created_at:type_name -> google.protobuf.Timestamp + 17, // 3: mcias.v1.WebAuthnCredentialInfo.last_used_at:type_name -> google.protobuf.Timestamp + 13, // 4: mcias.v1.ListWebAuthnCredentialsResponse.credentials:type_name -> mcias.v1.WebAuthnCredentialInfo + 0, // 5: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest + 2, // 6: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest + 4, // 7: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest + 6, // 8: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest + 8, // 9: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest + 10, // 10: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest + 12, // 11: mcias.v1.AuthService.ListWebAuthnCredentials:input_type -> mcias.v1.ListWebAuthnCredentialsRequest + 15, // 12: mcias.v1.AuthService.RemoveWebAuthnCredential:input_type -> mcias.v1.RemoveWebAuthnCredentialRequest + 1, // 13: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse + 3, // 14: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse + 5, // 15: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse + 7, // 16: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse + 9, // 17: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse + 11, // 18: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse + 14, // 19: mcias.v1.AuthService.ListWebAuthnCredentials:output_type -> mcias.v1.ListWebAuthnCredentialsResponse + 16, // 20: mcias.v1.AuthService.RemoveWebAuthnCredential:output_type -> mcias.v1.RemoveWebAuthnCredentialResponse + 13, // [13:21] is the sub-list for method output_type + 5, // [5:13] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_mcias_v1_auth_proto_init() } @@ -674,7 +994,7 @@ func file_mcias_v1_auth_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)), NumEnums: 0, - NumMessages: 12, + NumMessages: 17, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/mcias/v1/auth_grpc.pb.go b/gen/mcias/v1/auth_grpc.pb.go index 7d5089f..7f4b0fb 100644 --- a/gen/mcias/v1/auth_grpc.pb.go +++ b/gen/mcias/v1/auth_grpc.pb.go @@ -21,12 +21,14 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login" - AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout" - AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken" - AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP" - AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP" - AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP" + AuthService_Login_FullMethodName = "/mcias.v1.AuthService/Login" + AuthService_Logout_FullMethodName = "/mcias.v1.AuthService/Logout" + AuthService_RenewToken_FullMethodName = "/mcias.v1.AuthService/RenewToken" + AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP" + AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP" + AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP" + AuthService_ListWebAuthnCredentials_FullMethodName = "/mcias.v1.AuthService/ListWebAuthnCredentials" + AuthService_RemoveWebAuthnCredential_FullMethodName = "/mcias.v1.AuthService/RemoveWebAuthnCredential" ) // AuthServiceClient is the client API for AuthService service. @@ -53,6 +55,12 @@ type AuthServiceClient interface { // RemoveTOTP removes TOTP from an account (admin only). // Requires: admin JWT in metadata. RemoveTOTP(ctx context.Context, in *RemoveTOTPRequest, opts ...grpc.CallOption) (*RemoveTOTPResponse, error) + // ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials. + // Requires: admin JWT in metadata. + ListWebAuthnCredentials(ctx context.Context, in *ListWebAuthnCredentialsRequest, opts ...grpc.CallOption) (*ListWebAuthnCredentialsResponse, error) + // RemoveWebAuthnCredential removes a specific WebAuthn credential. + // Requires: admin JWT in metadata. + RemoveWebAuthnCredential(ctx context.Context, in *RemoveWebAuthnCredentialRequest, opts ...grpc.CallOption) (*RemoveWebAuthnCredentialResponse, error) } type authServiceClient struct { @@ -123,6 +131,26 @@ func (c *authServiceClient) RemoveTOTP(ctx context.Context, in *RemoveTOTPReques return out, nil } +func (c *authServiceClient) ListWebAuthnCredentials(ctx context.Context, in *ListWebAuthnCredentialsRequest, opts ...grpc.CallOption) (*ListWebAuthnCredentialsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListWebAuthnCredentialsResponse) + err := c.cc.Invoke(ctx, AuthService_ListWebAuthnCredentials_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) RemoveWebAuthnCredential(ctx context.Context, in *RemoveWebAuthnCredentialRequest, opts ...grpc.CallOption) (*RemoveWebAuthnCredentialResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RemoveWebAuthnCredentialResponse) + err := c.cc.Invoke(ctx, AuthService_RemoveWebAuthnCredential_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AuthServiceServer is the server API for AuthService service. // All implementations must embed UnimplementedAuthServiceServer // for forward compatibility. @@ -147,6 +175,12 @@ type AuthServiceServer interface { // RemoveTOTP removes TOTP from an account (admin only). // Requires: admin JWT in metadata. RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) + // ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials. + // Requires: admin JWT in metadata. + ListWebAuthnCredentials(context.Context, *ListWebAuthnCredentialsRequest) (*ListWebAuthnCredentialsResponse, error) + // RemoveWebAuthnCredential removes a specific WebAuthn credential. + // Requires: admin JWT in metadata. + RemoveWebAuthnCredential(context.Context, *RemoveWebAuthnCredentialRequest) (*RemoveWebAuthnCredentialResponse, error) mustEmbedUnimplementedAuthServiceServer() } @@ -175,6 +209,12 @@ func (UnimplementedAuthServiceServer) ConfirmTOTP(context.Context, *ConfirmTOTPR func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) { return nil, status.Error(codes.Unimplemented, "method RemoveTOTP not implemented") } +func (UnimplementedAuthServiceServer) ListWebAuthnCredentials(context.Context, *ListWebAuthnCredentialsRequest) (*ListWebAuthnCredentialsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListWebAuthnCredentials not implemented") +} +func (UnimplementedAuthServiceServer) RemoveWebAuthnCredential(context.Context, *RemoveWebAuthnCredentialRequest) (*RemoveWebAuthnCredentialResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveWebAuthnCredential not implemented") +} func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {} func (UnimplementedAuthServiceServer) testEmbeddedByValue() {} @@ -304,6 +344,42 @@ func _AuthService_RemoveTOTP_Handler(srv interface{}, ctx context.Context, dec f return interceptor(ctx, in, info, handler) } +func _AuthService_ListWebAuthnCredentials_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListWebAuthnCredentialsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).ListWebAuthnCredentials(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_ListWebAuthnCredentials_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).ListWebAuthnCredentials(ctx, req.(*ListWebAuthnCredentialsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_RemoveWebAuthnCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RemoveWebAuthnCredentialRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).RemoveWebAuthnCredential(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_RemoveWebAuthnCredential_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).RemoveWebAuthnCredential(ctx, req.(*RemoveWebAuthnCredentialRequest)) + } + return interceptor(ctx, in, info, handler) +} + // AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -335,6 +411,14 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{ MethodName: "RemoveTOTP", Handler: _AuthService_RemoveTOTP_Handler, }, + { + MethodName: "ListWebAuthnCredentials", + Handler: _AuthService_ListWebAuthnCredentials_Handler, + }, + { + MethodName: "RemoveWebAuthnCredential", + Handler: _AuthService_RemoveWebAuthnCredential_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "mcias/v1/auth.proto", diff --git a/go.mod b/go.mod index c0d8563..2c41674 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/pelletier/go-toml/v2 v2.2.4 - golang.org/x/crypto v0.45.0 - golang.org/x/term v0.37.0 + golang.org/x/crypto v0.49.0 + golang.org/x/term v0.41.0 google.golang.org/grpc v1.74.2 google.golang.org/protobuf v1.36.7 modernc.org/sqlite v1.46.1 @@ -16,13 +16,20 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/webauthn v0.16.1 // 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/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/x448/float16 v0.8.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 243c7fe..ce1be21 100644 --- a/go.sum +++ b/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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU= +github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ= +github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk= +github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= @@ -14,6 +22,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -32,8 +42,10 @@ 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= @@ -46,25 +58,25 @@ 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/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/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/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= diff --git a/internal/config/config.go b/internal/config/config.go index 1cb2712..3954aee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,18 +8,29 @@ import ( "fmt" "net" "os" + "strings" "time" "github.com/pelletier/go-toml/v2" ) // Config is the top-level configuration structure parsed from the TOML file. -type Config struct { +type Config struct { //nolint:govet // fieldalignment: TOML section order is more readable Server ServerConfig `toml:"server"` MasterKey MasterKeyConfig `toml:"master_key"` Database DatabaseConfig `toml:"database"` Tokens TokensConfig `toml:"tokens"` Argon2 Argon2Config `toml:"argon2"` + WebAuthn WebAuthnConfig `toml:"webauthn"` +} + +// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn] +// section disables WebAuthn support. If any field is set, RPID and RPOrigin are +// required and RPOrigin must use the HTTPS scheme. +type WebAuthnConfig struct { + RPID string `toml:"rp_id"` + RPOrigin string `toml:"rp_origin"` + DisplayName string `toml:"display_name"` } // ServerConfig holds HTTP listener and TLS settings. @@ -222,6 +233,19 @@ func (c *Config) validate() error { errs = append(errs, errors.New("master_key: only one of passphrase_env or keyfile may be set")) } + // WebAuthn — if any field is set, RPID and RPOrigin are required. + hasWebAuthn := c.WebAuthn.RPID != "" || c.WebAuthn.RPOrigin != "" || c.WebAuthn.DisplayName != "" + if hasWebAuthn { + if c.WebAuthn.RPID == "" { + errs = append(errs, errors.New("webauthn.rp_id is required when webauthn is configured")) + } + if c.WebAuthn.RPOrigin == "" { + errs = append(errs, errors.New("webauthn.rp_origin is required when webauthn is configured")) + } else if !strings.HasPrefix(c.WebAuthn.RPOrigin, "https://") { + errs = append(errs, fmt.Errorf("webauthn.rp_origin must use the https:// scheme (got %q)", c.WebAuthn.RPOrigin)) + } + } + return errors.Join(errs...) } @@ -233,3 +257,8 @@ func (c *Config) AdminExpiry() time.Duration { return c.Tokens.AdminExpiry.Durat // ServiceExpiry returns the configured service token expiry duration. func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.Duration } + +// WebAuthnEnabled reports whether WebAuthn/passkey support is configured. +func (c *Config) WebAuthnEnabled() bool { + return c.WebAuthn.RPID != "" && c.WebAuthn.RPOrigin != "" +} diff --git a/internal/db/migrate.go b/internal/db/migrate.go index be691d9..4d3891d 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -22,7 +22,7 @@ var migrationsFS embed.FS // LatestSchemaVersion is the highest migration version defined in the // migrations/ directory. Update this constant whenever a new migration file // is added. -const LatestSchemaVersion = 7 +const LatestSchemaVersion = 9 // newMigrate constructs a migrate.Migrate instance backed by the embedded SQL // files. It opens a dedicated *sql.DB using the same DSN as the main diff --git a/internal/db/migrations/000009_webauthn_credentials.down.sql b/internal/db/migrations/000009_webauthn_credentials.down.sql new file mode 100644 index 0000000..554afad --- /dev/null +++ b/internal/db/migrations/000009_webauthn_credentials.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS webauthn_credentials; diff --git a/internal/db/migrations/000009_webauthn_credentials.up.sql b/internal/db/migrations/000009_webauthn_credentials.up.sql new file mode 100644 index 0000000..88aebf0 --- /dev/null +++ b/internal/db/migrations/000009_webauthn_credentials.up.sql @@ -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); diff --git a/internal/db/webauthn.go b/internal/db/webauthn.go new file mode 100644 index 0000000..e4bc99c --- /dev/null +++ b/internal/db/webauthn.go @@ -0,0 +1,208 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + + "git.wntrmute.dev/kyle/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) +} diff --git a/internal/db/webauthn_test.go b/internal/db/webauthn_test.go new file mode 100644 index 0000000..c8ffe92 --- /dev/null +++ b/internal/db/webauthn_test.go @@ -0,0 +1,251 @@ +package db + +import ( + "errors" + "testing" + + "git.wntrmute.dev/kyle/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) + } +} diff --git a/internal/grpcserver/webauthn.go b/internal/grpcserver/webauthn.go new file mode 100644 index 0000000..5081d95 --- /dev/null +++ b/internal/grpcserver/webauthn.go @@ -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/kyle/mcias/gen/mcias/v1" + "git.wntrmute.dev/kyle/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 +} diff --git a/internal/model/model.go b/internal/model/model.go index 0882999..b93b741 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -213,6 +213,11 @@ const ( EventTokenDelegateGranted = "token_delegate_granted" EventTokenDelegateRevoked = "token_delegate_revoked" + + EventWebAuthnEnrolled = "webauthn_enrolled" + EventWebAuthnRemoved = "webauthn_removed" + EventWebAuthnLoginOK = "webauthn_login_ok" + EventWebAuthnLoginFail = "webauthn_login_fail" ) // ServiceAccountDelegate records that a specific account has been granted @@ -229,6 +234,26 @@ type ServiceAccountDelegate struct { GranteeID int64 `json:"-"` } +// WebAuthnCredential holds a stored WebAuthn/passkey credential. +// Credential IDs and public keys are encrypted at rest with AES-256-GCM; +// decrypted values must never be logged or included in API responses. +type WebAuthnCredential struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + Name string `json:"name"` + AAGUID string `json:"aaguid"` + Transports string `json:"transports,omitempty"` + CredentialIDEnc []byte `json:"-"` + CredentialIDNonce []byte `json:"-"` + PublicKeyEnc []byte `json:"-"` + PublicKeyNonce []byte `json:"-"` + ID int64 `json:"id"` + AccountID int64 `json:"-"` + SignCount uint32 `json:"sign_count"` + Discoverable bool `json:"discoverable"` +} + // PolicyRuleRecord is the database representation of a policy rule. // RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields). // The ID, Priority, and Description are stored as dedicated columns. diff --git a/internal/policy/defaults.go b/internal/policy/defaults.go index fe05478..7e8887e 100644 --- a/internal/policy/defaults.go +++ b/internal/policy/defaults.go @@ -81,6 +81,16 @@ var defaultRules = []Rule{ OwnerMatchesSubject: true, Effect: Allow, }, + { + // Self-service WebAuthn enrollment: any authenticated human account may + // register and manage their own passkeys/security keys. The handler + // verifies the subject matches before writing. Mirrors TOTP rule -3. + ID: -8, + Description: "Self-service: any principal may enroll their own WebAuthn credentials", + Priority: 0, + Actions: []Action{ActionEnrollWebAuthn}, + Effect: Allow, + }, { // Public endpoints: token validation and login do not require // authentication. The middleware exempts them from RequireAuth entirely; diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 65dff8b..fc5c1f3 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -48,6 +48,9 @@ const ( ActionListRules Action = "policy:list" ActionManageRules Action = "policy:manage" + + ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service + ActionRemoveWebAuthn Action = "webauthn:remove" // admin ) // ResourceType identifies what kind of object a request targets. @@ -60,6 +63,7 @@ const ( ResourceAuditLog ResourceType = "audit_log" ResourceTOTP ResourceType = "totp" ResourcePolicy ResourceType = "policy" + ResourceWebAuthn ResourceType = "webauthn" ) // Effect is the outcome of policy evaluation. diff --git a/internal/server/handlers_webauthn.go b/internal/server/handlers_webauthn.go new file mode 100644 index 0000000..68697b4 --- /dev/null +++ b/internal/server/handlers_webauthn.go @@ -0,0 +1,741 @@ +// 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/kyle/mcias/internal/audit" + "git.wntrmute.dev/kyle/mcias/internal/auth" + "git.wntrmute.dev/kyle/mcias/internal/crypto" + "git.wntrmute.dev/kyle/mcias/internal/middleware" + "git.wntrmute.dev/kyle/mcias/internal/model" + "git.wntrmute.dev/kyle/mcias/internal/token" + mciaswebauthn "git.wntrmute.dev/kyle/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) + + // Issue JWT. + roles, err := s.db.GetRoles(acct.ID) + if err != nil { + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + 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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 700f0b2..4744e4a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -308,6 +308,13 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /v1/auth/totp/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll))) mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm))) + // WebAuthn registration endpoints (require valid token; self-service). + mux.Handle("POST /v1/auth/webauthn/register/begin", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterBegin))) + mux.Handle("POST /v1/auth/webauthn/register/finish", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterFinish))) + // WebAuthn login endpoints (public, rate-limited). + mux.Handle("POST /v1/auth/webauthn/login/begin", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginBegin))) + mux.Handle("POST /v1/auth/webauthn/login/finish", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginFinish))) + // Policy-gated endpoints (formerly admin-only; now controlled by the engine). mux.Handle("DELETE /v1/auth/totp", requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove))) @@ -338,6 +345,11 @@ func (s *Server) Handler() http.Handler { requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds))) mux.Handle("PUT /v1/accounts/{id}/pgcreds", requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds))) + // WebAuthn credential management (policy-gated). + mux.Handle("GET /v1/accounts/{id}/webauthn", + requirePolicy(policy.ActionReadAccount, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleListWebAuthnCredentials))) + mux.Handle("DELETE /v1/accounts/{id}/webauthn/{credentialId}", + requirePolicy(policy.ActionRemoveWebAuthn, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleDeleteWebAuthnCredential))) mux.Handle("GET /v1/audit", requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit))) mux.Handle("GET /v1/accounts/{id}/tags", diff --git a/internal/ui/handlers_accounts.go b/internal/ui/handlers_accounts.go index e81ffa0..65b9ca5 100644 --- a/internal/ui/handlers_accounts.go +++ b/internal/ui/handlers_accounts.go @@ -197,6 +197,15 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) { } } + // Load WebAuthn credentials for the account detail page. + var webAuthnCreds []*model.WebAuthnCredential + if u.cfg.WebAuthnEnabled() { + webAuthnCreds, err = u.db.GetWebAuthnCredentials(acct.ID) + if err != nil { + u.logger.Warn("load webauthn credentials", "error", err) + } + } + u.render(w, "account_detail", AccountDetailData{ PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, Account: acct, @@ -211,6 +220,9 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) { TokenDelegates: tokenDelegates, DelegatableAccounts: delegatableAccounts, CanIssueToken: true, // account_detail is admin-only, so admin can always issue + WebAuthnCreds: webAuthnCreds, + DeletePrefix: "/accounts/" + acct.UUID + "/webauthn", + WebAuthnEnabled: u.cfg.WebAuthnEnabled(), }) } diff --git a/internal/ui/handlers_auth.go b/internal/ui/handlers_auth.go index 9ea270e..db70369 100644 --- a/internal/ui/handlers_auth.go +++ b/internal/ui/handlers_auth.go @@ -13,7 +13,9 @@ import ( // handleLoginPage renders the login form. func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) { - u.render(w, "login", LoginData{}) + u.render(w, "login", LoginData{ + WebAuthnEnabled: u.cfg.WebAuthnEnabled(), + }) } // handleLoginPost processes username+password (step 1) or TOTP code (step 2). @@ -290,13 +292,30 @@ func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, target // handleProfilePage renders the profile page for the currently logged-in user. func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) { csrfToken, _ := u.setCSRFCookies(w) - u.render(w, "profile", ProfileData{ + claims := claimsFromContext(r.Context()) + + data := ProfileData{ PageData: PageData{ CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r), }, - }) + WebAuthnEnabled: u.cfg.WebAuthnEnabled(), + DeletePrefix: "/profile/webauthn", + } + + // Load WebAuthn credentials for the profile page. + if u.cfg.WebAuthnEnabled() && claims != nil { + acct, err := u.db.GetAccountByUUID(claims.Subject) + if err == nil { + 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 diff --git a/internal/ui/handlers_webauthn.go b/internal/ui/handlers_webauthn.go new file mode 100644 index 0000000..4016818 --- /dev/null +++ b/internal/ui/handlers_webauthn.go @@ -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/kyle/mcias/internal/audit" + "git.wntrmute.dev/kyle/mcias/internal/auth" + "git.wntrmute.dev/kyle/mcias/internal/crypto" + "git.wntrmute.dev/kyle/mcias/internal/model" + "git.wntrmute.dev/kyle/mcias/internal/token" + mciaswebauthn "git.wntrmute.dev/kyle/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}) +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 1632dae..232916a 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -177,6 +177,13 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge } return *actorID == *cred.OwnerID }, + // derefTime dereferences a *time.Time, returning the zero time for nil. + "derefTime": func(p *time.Time) time.Time { + if p == nil { + return time.Time{} + } + return *p + }, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "gt": func(a, b int) bool { return a > b }, @@ -213,6 +220,8 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge "templates/fragments/password_reset_form.html", "templates/fragments/password_change_form.html", "templates/fragments/token_delegates.html", + "templates/fragments/webauthn_credentials.html", + "templates/fragments/webauthn_enroll.html", } base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...) if err != nil { @@ -378,6 +387,9 @@ func (u *UIServer) Register(mux *http.ServeMux) { uiMux.HandleFunc("GET /login", u.handleLoginPage) uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost))) uiMux.HandleFunc("POST /logout", u.handleLogout) + // WebAuthn login routes (public, rate-limited). + uiMux.Handle("POST /login/webauthn/begin", loginRateLimit(http.HandlerFunc(u.handleWebAuthnLoginBegin))) + uiMux.Handle("POST /login/webauthn/finish", loginRateLimit(http.HandlerFunc(u.handleWebAuthnLoginFinish))) // Protected routes. // @@ -432,6 +444,12 @@ func (u *UIServer) Register(mux *http.ServeMux) { // Profile routes — accessible to any authenticated user (not admin-only). uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage))) uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword)))) + // WebAuthn profile routes (enrollment and management). + uiMux.Handle("POST /profile/webauthn/begin", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnBegin)))) + uiMux.Handle("POST /profile/webauthn/finish", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnFinish)))) + uiMux.Handle("DELETE /profile/webauthn/{id}", authed(u.requireCSRF(http.HandlerFunc(u.handleWebAuthnDelete)))) + // Admin WebAuthn management. + uiMux.Handle("DELETE /accounts/{id}/webauthn/{credentialId}", admin(u.handleAdminWebAuthnDelete)) // Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a // catch-all for all UI paths; the more-specific /v1/ API patterns registered @@ -729,6 +747,8 @@ type LoginData struct { // a short-lived server-side nonce is issued after successful password // verification, and only the nonce is embedded in the TOTP step form. Nonce string // single-use server-side nonce replacing the password hidden field + // WebAuthnEnabled indicates whether the passkey login button should appear. + WebAuthnEnabled bool } // DashboardData is the view model for the dashboard page. @@ -746,7 +766,7 @@ type AccountsData struct { } // AccountDetailData is the view model for the account detail page. -type AccountDetailData struct { +type AccountDetailData struct { //nolint:govet // fieldalignment: readability over alignment for view model Account *model.Account // PGCred is nil if none stored or the account is not a system account. PGCred *model.PGCredential @@ -772,10 +792,15 @@ type AccountDetailData struct { AllRoles []string Tags []string Tokens []*model.TokenRecord + // WebAuthnCreds lists the WebAuthn credentials for this account (metadata only). + WebAuthnCreds []*model.WebAuthnCredential + // DeletePrefix is the URL prefix for WebAuthn credential delete buttons. + DeletePrefix string // CanIssueToken is true when the viewing actor may issue tokens for this // system account (admin role or explicit delegate grant). // Placed last to minimise GC scan area. - CanIssueToken bool + CanIssueToken bool + WebAuthnEnabled bool } // ServiceAccountsData is the view model for the /service-accounts page. @@ -832,8 +857,11 @@ type PoliciesData struct { } // ProfileData is the view model for the profile/settings page. -type ProfileData struct { +type ProfileData struct { //nolint:govet // fieldalignment: readability over alignment for view model PageData + WebAuthnCreds []*model.WebAuthnCredential + DeletePrefix string // URL prefix for delete buttons (e.g. "/profile/webauthn") + WebAuthnEnabled bool } // PGCredsData is the view model for the "My PG Credentials" list page. diff --git a/internal/webauthn/adapter.go b/internal/webauthn/adapter.go new file mode 100644 index 0000000..ae1e97b --- /dev/null +++ b/internal/webauthn/adapter.go @@ -0,0 +1,28 @@ +// Package webauthn provides the adapter between the go-webauthn library and +// MCIAS internal types. It handles WebAuthn instance configuration and +// encryption/decryption of credential material stored in the database. +package webauthn + +import ( + "fmt" + + "github.com/go-webauthn/webauthn/webauthn" + + "git.wntrmute.dev/kyle/mcias/internal/config" +) + +// NewWebAuthn creates a configured go-webauthn instance from MCIAS config. +func NewWebAuthn(cfg *config.WebAuthnConfig) (*webauthn.WebAuthn, error) { + if cfg.RPID == "" || cfg.RPOrigin == "" { + return nil, fmt.Errorf("webauthn: RPID and RPOrigin are required") + } + displayName := cfg.DisplayName + if displayName == "" { + displayName = "MCIAS" + } + return webauthn.New(&webauthn.Config{ + RPID: cfg.RPID, + RPDisplayName: displayName, + RPOrigins: []string{cfg.RPOrigin}, + }) +} diff --git a/internal/webauthn/adapter_test.go b/internal/webauthn/adapter_test.go new file mode 100644 index 0000000..916c923 --- /dev/null +++ b/internal/webauthn/adapter_test.go @@ -0,0 +1,75 @@ +package webauthn + +import ( + "testing" + + libwebauthn "github.com/go-webauthn/webauthn/webauthn" + + "git.wntrmute.dev/kyle/mcias/internal/config" +) + +func TestNewWebAuthn(t *testing.T) { + cfg := &config.WebAuthnConfig{ + RPID: "example.com", + RPOrigin: "https://example.com", + DisplayName: "Test App", + } + wa, err := NewWebAuthn(cfg) + if err != nil { + t.Fatalf("NewWebAuthn: %v", err) + } + if wa == nil { + t.Fatal("expected non-nil WebAuthn instance") + } +} + +func TestNewWebAuthnMissingFields(t *testing.T) { + _, err := NewWebAuthn(&config.WebAuthnConfig{}) + if err == nil { + t.Error("expected error for empty config") + } + + _, err = NewWebAuthn(&config.WebAuthnConfig{RPID: "example.com"}) + if err == nil { + t.Error("expected error for missing RPOrigin") + } +} + +func TestNewWebAuthnDefaultDisplayName(t *testing.T) { + cfg := &config.WebAuthnConfig{ + RPID: "example.com", + RPOrigin: "https://example.com", + } + wa, err := NewWebAuthn(cfg) + if err != nil { + t.Fatalf("NewWebAuthn: %v", err) + } + if wa == nil { + t.Fatal("expected non-nil WebAuthn instance") + } +} + +func TestAccountUserInterface(t *testing.T) { + uuidBytes := []byte("12345678-1234-1234-1234-123456789abc") + creds := []libwebauthn.Credential{ + {ID: []byte("cred1")}, + {ID: []byte("cred2")}, + } + user := NewAccountUser(uuidBytes, "alice", creds) + + // Verify interface compliance. + var _ libwebauthn.User = user + + if string(user.WebAuthnID()) != string(uuidBytes) { + t.Error("WebAuthnID mismatch") + } + if user.WebAuthnName() != "alice" { + t.Errorf("WebAuthnName = %q, want %q", user.WebAuthnName(), "alice") + } + if user.WebAuthnDisplayName() != "alice" { + t.Errorf("WebAuthnDisplayName = %q, want %q", user.WebAuthnDisplayName(), "alice") + } + if len(user.WebAuthnCredentials()) != 2 { + t.Errorf("WebAuthnCredentials len = %d, want 2", len(user.WebAuthnCredentials())) + } +} diff --git a/internal/webauthn/convert.go b/internal/webauthn/convert.go new file mode 100644 index 0000000..9a0e97a --- /dev/null +++ b/internal/webauthn/convert.go @@ -0,0 +1,99 @@ +package webauthn + +import ( + "encoding/hex" + "fmt" + "strings" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + + "git.wntrmute.dev/kyle/mcias/internal/crypto" + "git.wntrmute.dev/kyle/mcias/internal/model" +) + +// DecryptCredential decrypts a stored WebAuthn credential's ID and public key +// and returns a webauthn.Credential suitable for the go-webauthn library. +func DecryptCredential(masterKey []byte, cred *model.WebAuthnCredential) (*webauthn.Credential, error) { + credID, err := crypto.OpenAESGCM(masterKey, cred.CredentialIDNonce, cred.CredentialIDEnc) + if err != nil { + return nil, fmt.Errorf("webauthn: decrypt credential ID: %w", err) + } + pubKey, err := crypto.OpenAESGCM(masterKey, cred.PublicKeyNonce, cred.PublicKeyEnc) + if err != nil { + return nil, fmt.Errorf("webauthn: decrypt public key: %w", err) + } + + // Parse transports from comma-separated string. + var transports []protocol.AuthenticatorTransport + if cred.Transports != "" { + for _, t := range strings.Split(cred.Transports, ",") { + transports = append(transports, protocol.AuthenticatorTransport(strings.TrimSpace(t))) + } + } + + // Parse AAGUID from hex string. + var aaguid []byte + if cred.AAGUID != "" { + aaguid, _ = hex.DecodeString(cred.AAGUID) + } + + return &webauthn.Credential{ + ID: credID, + PublicKey: pubKey, + Transport: transports, + Flags: webauthn.CredentialFlags{ + UserPresent: true, + UserVerified: true, + BackupEligible: cred.Discoverable, + }, + Authenticator: webauthn.Authenticator{ + AAGUID: aaguid, + SignCount: cred.SignCount, + }, + }, nil +} + +// DecryptCredentials decrypts all stored credentials for use with the library. +func DecryptCredentials(masterKey []byte, dbCreds []*model.WebAuthnCredential) ([]webauthn.Credential, error) { + result := make([]webauthn.Credential, 0, len(dbCreds)) + for _, c := range dbCreds { + decrypted, err := DecryptCredential(masterKey, c) + if err != nil { + return nil, err + } + result = append(result, *decrypted) + } + return result, nil +} + +// EncryptCredential encrypts a library credential for database storage. +// Returns a model.WebAuthnCredential with encrypted fields populated. +func EncryptCredential(masterKey []byte, cred *webauthn.Credential, name string, discoverable bool) (*model.WebAuthnCredential, error) { + credIDEnc, credIDNonce, err := crypto.SealAESGCM(masterKey, cred.ID) + if err != nil { + return nil, fmt.Errorf("webauthn: encrypt credential ID: %w", err) + } + pubKeyEnc, pubKeyNonce, err := crypto.SealAESGCM(masterKey, cred.PublicKey) + if err != nil { + return nil, fmt.Errorf("webauthn: encrypt public key: %w", err) + } + + // Serialize transports as comma-separated string. + var transportStrs []string + for _, t := range cred.Transport { + transportStrs = append(transportStrs, string(t)) + } + + return &model.WebAuthnCredential{ + Name: name, + CredentialIDEnc: credIDEnc, + CredentialIDNonce: credIDNonce, + PublicKeyEnc: pubKeyEnc, + PublicKeyNonce: pubKeyNonce, + AAGUID: hex.EncodeToString(cred.Authenticator.AAGUID), + SignCount: cred.Authenticator.SignCount, + Discoverable: discoverable, + Transports: strings.Join(transportStrs, ","), + }, nil +} diff --git a/internal/webauthn/convert_test.go b/internal/webauthn/convert_test.go new file mode 100644 index 0000000..e0eb099 --- /dev/null +++ b/internal/webauthn/convert_test.go @@ -0,0 +1,148 @@ +package webauthn + +import ( + "bytes" + "testing" + + "github.com/go-webauthn/webauthn/protocol" + libwebauthn "github.com/go-webauthn/webauthn/webauthn" + + "git.wntrmute.dev/kyle/mcias/internal/crypto" + "git.wntrmute.dev/kyle/mcias/internal/model" +) + +func testMasterKey(t *testing.T) []byte { + t.Helper() + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + return key +} + +func TestEncryptDecryptRoundTrip(t *testing.T) { + masterKey := testMasterKey(t) + + original := &libwebauthn.Credential{ + ID: []byte("credential-id-12345"), + PublicKey: []byte("public-key-bytes-here"), + Transport: []protocol.AuthenticatorTransport{ + protocol.USB, + protocol.NFC, + }, + Flags: libwebauthn.CredentialFlags{ + UserPresent: true, + UserVerified: true, + BackupEligible: true, + }, + Authenticator: libwebauthn.Authenticator{ + AAGUID: []byte{0x2f, 0xc0, 0x57, 0x9f, 0x81, 0x13, 0x47, 0xea, 0xb1, 0x16, 0xbb, 0x5a, 0x8d, 0xb9, 0x20, 0x2a}, + SignCount: 42, + }, + } + + // Encrypt. + encrypted, err := EncryptCredential(masterKey, original, "YubiKey 5", true) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if encrypted.Name != "YubiKey 5" { + t.Errorf("Name = %q, want %q", encrypted.Name, "YubiKey 5") + } + if !encrypted.Discoverable { + t.Error("expected discoverable=true") + } + if encrypted.SignCount != 42 { + t.Errorf("SignCount = %d, want 42", encrypted.SignCount) + } + if encrypted.Transports != "usb,nfc" { + t.Errorf("Transports = %q, want %q", encrypted.Transports, "usb,nfc") + } + + // Encrypted fields should not be plaintext. + if bytes.Equal(encrypted.CredentialIDEnc, original.ID) { + t.Error("credential ID should be encrypted") + } + if bytes.Equal(encrypted.PublicKeyEnc, original.PublicKey) { + t.Error("public key should be encrypted") + } + + // Decrypt. + decrypted, err := DecryptCredential(masterKey, encrypted) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if !bytes.Equal(decrypted.ID, original.ID) { + t.Errorf("credential ID mismatch after roundtrip") + } + if !bytes.Equal(decrypted.PublicKey, original.PublicKey) { + t.Errorf("public key mismatch after roundtrip") + } + if decrypted.Authenticator.SignCount != 42 { + t.Errorf("SignCount = %d, want 42", decrypted.Authenticator.SignCount) + } + if len(decrypted.Transport) != 2 { + t.Errorf("expected 2 transports, got %d", len(decrypted.Transport)) + } +} + +func TestDecryptCredentials(t *testing.T) { + masterKey := testMasterKey(t) + + // Create two encrypted credentials. + var dbCreds []*model.WebAuthnCredential + for i := range 3 { + cred := &libwebauthn.Credential{ + ID: []byte{byte(i), 1, 2, 3}, + PublicKey: []byte{byte(i), 4, 5, 6}, + Authenticator: libwebauthn.Authenticator{ + SignCount: uint32(i), + }, + } + enc, err := EncryptCredential(masterKey, cred, "key", false) + if err != nil { + t.Fatalf("encrypt %d: %v", i, err) + } + dbCreds = append(dbCreds, enc) + } + + decrypted, err := DecryptCredentials(masterKey, dbCreds) + if err != nil { + t.Fatalf("decrypt all: %v", err) + } + if len(decrypted) != 3 { + t.Fatalf("expected 3 decrypted, got %d", len(decrypted)) + } + for i, d := range decrypted { + if d.ID[0] != byte(i) { + t.Errorf("cred %d: ID[0] = %d, want %d", i, d.ID[0], byte(i)) + } + } +} + +func TestDecryptWithWrongKey(t *testing.T) { + masterKey := testMasterKey(t) + wrongKey := make([]byte, 32) + for i := range wrongKey { + wrongKey[i] = byte(i + 100) + } + + // Encrypt with correct key. + enc, nonce, err := crypto.SealAESGCM(masterKey, []byte("secret")) + if err != nil { + t.Fatalf("seal: %v", err) + } + + dbCred := &model.WebAuthnCredential{ + CredentialIDEnc: enc, + CredentialIDNonce: nonce, + PublicKeyEnc: enc, + PublicKeyNonce: nonce, + } + + // Decrypt with wrong key should fail. + _, err = DecryptCredential(wrongKey, dbCred) + if err == nil { + t.Error("expected error decrypting with wrong key") + } +} diff --git a/internal/webauthn/user.go b/internal/webauthn/user.go new file mode 100644 index 0000000..600b649 --- /dev/null +++ b/internal/webauthn/user.go @@ -0,0 +1,37 @@ +package webauthn + +import ( + "github.com/go-webauthn/webauthn/webauthn" +) + +// AccountUser implements the webauthn.User interface for an MCIAS account. +// The WebAuthnCredentials field must be populated with decrypted credentials +// before passing to the library. +type AccountUser struct { + id []byte // UUID as bytes + name string + displayName string + credentials []webauthn.Credential +} + +// NewAccountUser creates a new AccountUser from account details and decrypted credentials. +func NewAccountUser(uuidBytes []byte, username string, creds []webauthn.Credential) *AccountUser { + return &AccountUser{ + id: uuidBytes, + name: username, + displayName: username, + credentials: creds, + } +} + +// WebAuthnID returns the user's unique ID as bytes. +func (u *AccountUser) WebAuthnID() []byte { return u.id } + +// WebAuthnName returns the user's login name. +func (u *AccountUser) WebAuthnName() string { return u.name } + +// WebAuthnDisplayName returns the user's display name. +func (u *AccountUser) WebAuthnDisplayName() string { return u.displayName } + +// WebAuthnCredentials returns the user's registered credentials. +func (u *AccountUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } diff --git a/openapi.yaml b/openapi.yaml index 401eb12..44ec464 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -86,6 +86,54 @@ components: type: boolean description: Whether TOTP is enrolled and required for this account. example: false + webauthn_enabled: + type: boolean + description: Whether at least one WebAuthn credential is registered. + example: false + webauthn_count: + type: integer + description: Number of registered WebAuthn credentials. + example: 0 + + WebAuthnCredentialInfo: + type: object + required: [id, name, sign_count, discoverable, created_at] + properties: + id: + type: integer + format: int64 + description: Database row ID. + example: 1 + name: + type: string + description: User-supplied label for the credential. + example: "YubiKey 5" + aaguid: + type: string + description: Authenticator Attestation GUID. + example: "2fc0579f-8113-47ea-b116-bb5a8db9202a" + sign_count: + type: integer + format: uint32 + description: Signature counter (used to detect cloned authenticators). + example: 42 + discoverable: + type: boolean + description: Whether this is a discoverable (passkey/resident) credential. + example: true + transports: + type: string + description: Comma-separated transport hints (usb, nfc, ble, internal). + example: "usb,nfc" + created_at: + type: string + format: date-time + example: "2026-03-11T09:00:00Z" + last_used_at: + type: string + format: date-time + nullable: true + example: "2026-03-15T14:30:00Z" AuditEvent: type: object @@ -847,6 +895,213 @@ paths: "404": $ref: "#/components/responses/NotFound" + # ── WebAuthn ────────────────────────────────────────────────────────────── + + /v1/auth/webauthn/register/begin: + post: + summary: Begin WebAuthn registration + description: | + Start a WebAuthn credential registration ceremony. Requires the current + password for re-authentication (same security model as TOTP enrollment). + Returns PublicKeyCredentialCreationOptions for the browser WebAuthn API. + operationId: beginWebAuthnRegister + tags: [Auth] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: + type: string + description: Current password for re-authentication. + name: + type: string + description: Optional label for the credential (e.g. "YubiKey 5"). + example: "YubiKey 5" + responses: + "200": + description: Registration options for navigator.credentials.create(). + content: + application/json: + schema: + type: object + description: PublicKeyCredentialCreationOptions (WebAuthn spec). + "401": + $ref: "#/components/responses/Unauthorized" + "429": + description: Account temporarily locked. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/auth/webauthn/register/finish: + post: + summary: Finish WebAuthn registration + description: | + Complete the WebAuthn credential registration ceremony. The request body + contains the authenticator's response from navigator.credentials.create(). + The credential is encrypted at rest with AES-256-GCM. + operationId: finishWebAuthnRegister + tags: [Auth] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + description: AuthenticatorAttestationResponse (WebAuthn spec). + responses: + "200": + description: Credential registered. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + + /v1/auth/webauthn/login/begin: + post: + summary: Begin WebAuthn login + description: | + Start a WebAuthn authentication ceremony. Public RPC — no auth required. + With a username: returns allowCredentials for the account's registered + credentials. Without a username: starts a discoverable (passkey) flow. + operationId: beginWebAuthnLogin + tags: [Public] + requestBody: + content: + application/json: + schema: + type: object + properties: + username: + type: string + description: Optional. If omitted, starts a discoverable (passkey) flow. + example: alice + responses: + "200": + description: Assertion options for navigator.credentials.get(). + content: + application/json: + schema: + type: object + description: PublicKeyCredentialRequestOptions (WebAuthn spec). + "429": + $ref: "#/components/responses/RateLimited" + + /v1/auth/webauthn/login/finish: + post: + summary: Finish WebAuthn login + description: | + Complete the WebAuthn authentication ceremony. Validates the assertion, + checks the sign counter, and issues a JWT. Public RPC — no auth required. + operationId: finishWebAuthnLogin + tags: [Public] + requestBody: + required: true + content: + application/json: + schema: + type: object + description: AuthenticatorAssertionResponse (WebAuthn spec). + responses: + "200": + description: Login successful. + content: + application/json: + schema: + $ref: "#/components/schemas/TokenResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + + /v1/accounts/{id}/webauthn: + get: + summary: List WebAuthn credentials (admin) + description: | + Returns metadata for all WebAuthn credentials registered to an account. + Credential material (IDs, public keys) is never included. + operationId: listWebAuthnCredentials + tags: [Admin — Accounts] + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Account UUID. + responses: + "200": + description: Credential metadata list. + content: + application/json: + schema: + type: object + properties: + credentials: + type: array + items: + $ref: "#/components/schemas/WebAuthnCredentialInfo" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /v1/accounts/{id}/webauthn/{credentialId}: + delete: + summary: Remove WebAuthn credential (admin) + description: | + Remove a specific WebAuthn credential from an account. Admin only. + operationId: deleteWebAuthnCredential + tags: [Admin — Accounts] + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Account UUID. + - name: credentialId + in: path + required: true + schema: + type: integer + format: int64 + description: Credential database row ID. + responses: + "204": + description: Credential removed. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /v1/token/issue: post: summary: Issue service account token (admin) diff --git a/proto/mcias/v1/auth.proto b/proto/mcias/v1/auth.proto index ec05c23..e4212df 100644 --- a/proto/mcias/v1/auth.proto +++ b/proto/mcias/v1/auth.proto @@ -75,6 +75,40 @@ message RemoveTOTPRequest { // RemoveTOTPResponse confirms removal. message RemoveTOTPResponse {} +// --- WebAuthn --- + +// ListWebAuthnCredentialsRequest lists metadata for an account's WebAuthn credentials. +message ListWebAuthnCredentialsRequest { + string account_id = 1; // UUID +} + +// WebAuthnCredentialInfo holds metadata about a stored WebAuthn credential. +// Credential material (IDs, public keys) is never included. +message WebAuthnCredentialInfo { + int64 id = 1; + string name = 2; + string aaguid = 3; + uint32 sign_count = 4; + bool discoverable = 5; + string transports = 6; + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp last_used_at = 8; +} + +// ListWebAuthnCredentialsResponse returns credential metadata. +message ListWebAuthnCredentialsResponse { + repeated WebAuthnCredentialInfo credentials = 1; +} + +// RemoveWebAuthnCredentialRequest removes a specific WebAuthn credential (admin). +message RemoveWebAuthnCredentialRequest { + string account_id = 1; // UUID + int64 credential_id = 2; +} + +// RemoveWebAuthnCredentialResponse confirms removal. +message RemoveWebAuthnCredentialResponse {} + // AuthService handles all authentication flows. service AuthService { // Login authenticates with username+password (+optional TOTP) and returns a JWT. @@ -100,4 +134,12 @@ service AuthService { // RemoveTOTP removes TOTP from an account (admin only). // Requires: admin JWT in metadata. rpc RemoveTOTP(RemoveTOTPRequest) returns (RemoveTOTPResponse); + + // ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials. + // Requires: admin JWT in metadata. + rpc ListWebAuthnCredentials(ListWebAuthnCredentialsRequest) returns (ListWebAuthnCredentialsResponse); + + // RemoveWebAuthnCredential removes a specific WebAuthn credential. + // Requires: admin JWT in metadata. + rpc RemoveWebAuthnCredential(RemoveWebAuthnCredentialRequest) returns (RemoveWebAuthnCredentialResponse); } diff --git a/web/static/openapi.yaml b/web/static/openapi.yaml index 401eb12..44ec464 100644 --- a/web/static/openapi.yaml +++ b/web/static/openapi.yaml @@ -86,6 +86,54 @@ components: type: boolean description: Whether TOTP is enrolled and required for this account. example: false + webauthn_enabled: + type: boolean + description: Whether at least one WebAuthn credential is registered. + example: false + webauthn_count: + type: integer + description: Number of registered WebAuthn credentials. + example: 0 + + WebAuthnCredentialInfo: + type: object + required: [id, name, sign_count, discoverable, created_at] + properties: + id: + type: integer + format: int64 + description: Database row ID. + example: 1 + name: + type: string + description: User-supplied label for the credential. + example: "YubiKey 5" + aaguid: + type: string + description: Authenticator Attestation GUID. + example: "2fc0579f-8113-47ea-b116-bb5a8db9202a" + sign_count: + type: integer + format: uint32 + description: Signature counter (used to detect cloned authenticators). + example: 42 + discoverable: + type: boolean + description: Whether this is a discoverable (passkey/resident) credential. + example: true + transports: + type: string + description: Comma-separated transport hints (usb, nfc, ble, internal). + example: "usb,nfc" + created_at: + type: string + format: date-time + example: "2026-03-11T09:00:00Z" + last_used_at: + type: string + format: date-time + nullable: true + example: "2026-03-15T14:30:00Z" AuditEvent: type: object @@ -847,6 +895,213 @@ paths: "404": $ref: "#/components/responses/NotFound" + # ── WebAuthn ────────────────────────────────────────────────────────────── + + /v1/auth/webauthn/register/begin: + post: + summary: Begin WebAuthn registration + description: | + Start a WebAuthn credential registration ceremony. Requires the current + password for re-authentication (same security model as TOTP enrollment). + Returns PublicKeyCredentialCreationOptions for the browser WebAuthn API. + operationId: beginWebAuthnRegister + tags: [Auth] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: + type: string + description: Current password for re-authentication. + name: + type: string + description: Optional label for the credential (e.g. "YubiKey 5"). + example: "YubiKey 5" + responses: + "200": + description: Registration options for navigator.credentials.create(). + content: + application/json: + schema: + type: object + description: PublicKeyCredentialCreationOptions (WebAuthn spec). + "401": + $ref: "#/components/responses/Unauthorized" + "429": + description: Account temporarily locked. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/auth/webauthn/register/finish: + post: + summary: Finish WebAuthn registration + description: | + Complete the WebAuthn credential registration ceremony. The request body + contains the authenticator's response from navigator.credentials.create(). + The credential is encrypted at rest with AES-256-GCM. + operationId: finishWebAuthnRegister + tags: [Auth] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + description: AuthenticatorAttestationResponse (WebAuthn spec). + responses: + "200": + description: Credential registered. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + + /v1/auth/webauthn/login/begin: + post: + summary: Begin WebAuthn login + description: | + Start a WebAuthn authentication ceremony. Public RPC — no auth required. + With a username: returns allowCredentials for the account's registered + credentials. Without a username: starts a discoverable (passkey) flow. + operationId: beginWebAuthnLogin + tags: [Public] + requestBody: + content: + application/json: + schema: + type: object + properties: + username: + type: string + description: Optional. If omitted, starts a discoverable (passkey) flow. + example: alice + responses: + "200": + description: Assertion options for navigator.credentials.get(). + content: + application/json: + schema: + type: object + description: PublicKeyCredentialRequestOptions (WebAuthn spec). + "429": + $ref: "#/components/responses/RateLimited" + + /v1/auth/webauthn/login/finish: + post: + summary: Finish WebAuthn login + description: | + Complete the WebAuthn authentication ceremony. Validates the assertion, + checks the sign counter, and issues a JWT. Public RPC — no auth required. + operationId: finishWebAuthnLogin + tags: [Public] + requestBody: + required: true + content: + application/json: + schema: + type: object + description: AuthenticatorAssertionResponse (WebAuthn spec). + responses: + "200": + description: Login successful. + content: + application/json: + schema: + $ref: "#/components/schemas/TokenResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + + /v1/accounts/{id}/webauthn: + get: + summary: List WebAuthn credentials (admin) + description: | + Returns metadata for all WebAuthn credentials registered to an account. + Credential material (IDs, public keys) is never included. + operationId: listWebAuthnCredentials + tags: [Admin — Accounts] + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Account UUID. + responses: + "200": + description: Credential metadata list. + content: + application/json: + schema: + type: object + properties: + credentials: + type: array + items: + $ref: "#/components/schemas/WebAuthnCredentialInfo" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /v1/accounts/{id}/webauthn/{credentialId}: + delete: + summary: Remove WebAuthn credential (admin) + description: | + Remove a specific WebAuthn credential from an account. Admin only. + operationId: deleteWebAuthnCredential + tags: [Admin — Accounts] + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: Account UUID. + - name: credentialId + in: path + required: true + schema: + type: integer + format: int64 + description: Credential database row ID. + responses: + "204": + description: Credential removed. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /v1/token/issue: post: summary: Issue service account token (admin) diff --git a/web/static/webauthn.js b/web/static/webauthn.js new file mode 100644 index 0000000..87ad173 --- /dev/null +++ b/web/static/webauthn.js @@ -0,0 +1,215 @@ +// webauthn.js — WebAuthn/passkey helpers for the MCIAS web UI. +// CSP-compliant: loaded as an external script, no inline code. + +(function () { + 'use strict'; + + // Base64URL encode/decode helpers for WebAuthn ArrayBuffer <-> JSON transport. + function base64urlEncode(buffer) { + var bytes = new Uint8Array(buffer); + var str = ''; + for (var i = 0; i < bytes.length; i++) { + str += String.fromCharCode(bytes[i]); + } + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + function base64urlDecode(str) { + str = str.replace(/-/g, '+').replace(/_/g, '/'); + while (str.length % 4) { str += '='; } + var binary = atob(str); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } + + // Get the CSRF token from the cookie for mutating requests. + function getCSRFToken() { + var match = document.cookie.match(/(?:^|;\s*)mcias_csrf=([^;]+)/); + return match ? match[1] : ''; + } + + function showError(id, msg) { + var el = document.getElementById(id); + if (el) { el.textContent = msg; el.style.display = ''; } + } + + function hideError(id) { + var el = document.getElementById(id); + if (el) { el.style.display = 'none'; el.textContent = ''; } + } + + // mciasWebAuthnRegister initiates a passkey/security-key registration. + window.mciasWebAuthnRegister = function (password, name, onSuccess, onError) { + if (!window.PublicKeyCredential) { + onError('WebAuthn is not supported in this browser.'); + return; + } + + var csrf = getCSRFToken(); + var savedNonce = ''; + + fetch('/profile/webauthn/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ password: password, name: name }) + }) + .then(function (resp) { + if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Registration failed'); }); + return resp.json(); + }) + .then(function (data) { + savedNonce = data.nonce; + var opts = data.options; + opts.publicKey.challenge = base64urlDecode(opts.publicKey.challenge); + if (opts.publicKey.user && opts.publicKey.user.id) { + opts.publicKey.user.id = base64urlDecode(opts.publicKey.user.id); + } + if (opts.publicKey.excludeCredentials) { + for (var i = 0; i < opts.publicKey.excludeCredentials.length; i++) { + opts.publicKey.excludeCredentials[i].id = base64urlDecode(opts.publicKey.excludeCredentials[i].id); + } + } + return navigator.credentials.create(opts); + }) + .then(function (credential) { + if (!credential) throw new Error('Registration cancelled'); + var credJSON = { + id: credential.id, + rawId: base64urlEncode(credential.rawId), + type: credential.type, + response: { + attestationObject: base64urlEncode(credential.response.attestationObject), + clientDataJSON: base64urlEncode(credential.response.clientDataJSON) + } + }; + if (credential.response.getTransports) { + credJSON.response.transports = credential.response.getTransports(); + } + return fetch('/profile/webauthn/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ nonce: savedNonce, name: name, credential: credJSON }) + }); + }) + .then(function (resp) { + if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Registration failed'); }); + return resp.json(); + }) + .then(function (result) { onSuccess(result); }) + .catch(function (err) { onError(err.message || 'Registration failed'); }); + }; + + // mciasWebAuthnLogin initiates a passkey login. + window.mciasWebAuthnLogin = function (username, onSuccess, onError) { + if (!window.PublicKeyCredential) { + onError('WebAuthn is not supported in this browser.'); + return; + } + + var savedNonce = ''; + + fetch('/login/webauthn/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: username || '' }) + }) + .then(function (resp) { + if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); }); + return resp.json(); + }) + .then(function (data) { + savedNonce = data.nonce; + var opts = data.options; + opts.publicKey.challenge = base64urlDecode(opts.publicKey.challenge); + if (opts.publicKey.allowCredentials) { + for (var i = 0; i < opts.publicKey.allowCredentials.length; i++) { + opts.publicKey.allowCredentials[i].id = base64urlDecode(opts.publicKey.allowCredentials[i].id); + } + } + return navigator.credentials.get(opts); + }) + .then(function (assertion) { + if (!assertion) throw new Error('Login cancelled'); + var credJSON = { + id: assertion.id, + rawId: base64urlEncode(assertion.rawId), + type: assertion.type, + response: { + authenticatorData: base64urlEncode(assertion.response.authenticatorData), + clientDataJSON: base64urlEncode(assertion.response.clientDataJSON), + signature: base64urlEncode(assertion.response.signature) + } + }; + if (assertion.response.userHandle) { + credJSON.response.userHandle = base64urlEncode(assertion.response.userHandle); + } + return fetch('/login/webauthn/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nonce: savedNonce, credential: credJSON }) + }); + }) + .then(function (resp) { + if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); }); + return resp.json(); + }) + .then(function () { onSuccess(); }) + .catch(function (err) { onError(err.message || 'Login failed'); }); + }; + + // Auto-wire the profile page enrollment button. + document.addEventListener('DOMContentLoaded', function () { + var enrollBtn = document.getElementById('webauthn-enroll-btn'); + if (enrollBtn) { + enrollBtn.addEventListener('click', function () { + var pw = document.getElementById('webauthn-password').value; + var name = document.getElementById('webauthn-name').value || 'Passkey'; + hideError('webauthn-enroll-error'); + hideError('webauthn-enroll-success'); + + if (!pw) { + showError('webauthn-enroll-error', 'Password is required.'); + return; + } + + enrollBtn.disabled = true; + enrollBtn.textContent = 'Waiting for authenticator...'; + + window.mciasWebAuthnRegister(pw, name, function () { + enrollBtn.disabled = false; + enrollBtn.textContent = 'Add Passkey'; + document.getElementById('webauthn-password').value = ''; + var msg = document.getElementById('webauthn-enroll-success'); + if (msg) { msg.textContent = 'Passkey registered successfully.'; msg.style.display = ''; } + // Reload the credentials list. + window.location.reload(); + }, function (err) { + enrollBtn.disabled = false; + enrollBtn.textContent = 'Add Passkey'; + showError('webauthn-enroll-error', err); + }); + }); + } + + // Auto-wire the login page passkey button. + var loginBtn = document.getElementById('webauthn-login-btn'); + if (loginBtn) { + loginBtn.addEventListener('click', function () { + hideError('webauthn-login-error'); + loginBtn.disabled = true; + loginBtn.textContent = 'Waiting for authenticator...'; + + window.mciasWebAuthnLogin('', function () { + window.location.href = '/dashboard'; + }, function (err) { + loginBtn.disabled = false; + loginBtn.textContent = 'Sign in with passkey'; + showError('webauthn-login-error', err); + }); + }); + } + }); +})(); diff --git a/web/templates/account_detail.html b/web/templates/account_detail.html index 70a06a8..47ed4fa 100644 --- a/web/templates/account_detail.html +++ b/web/templates/account_detail.html @@ -15,6 +15,7 @@
Status
{{template "account_status" .}}
TOTP
{{if .Account.TOTPRequired}}Enabled{{else}}Disabled{{end}}
+ {{if .WebAuthnEnabled}}
Passkeys
{{len .WebAuthnCreds}} registered
{{end}}
Created
{{formatTime .Account.CreatedAt}}
Updated
{{formatTime .Account.UpdatedAt}}
@@ -44,6 +45,12 @@
{{template "token_delegates" .}}
{{end}} +{{if .WebAuthnEnabled}} +
+

Passkeys

+ {{template "webauthn_credentials" .}} +
+{{end}}

Tags

{{template "tags_editor" .}}
diff --git a/web/templates/fragments/webauthn_credentials.html b/web/templates/fragments/webauthn_credentials.html new file mode 100644 index 0000000..76b916c --- /dev/null +++ b/web/templates/fragments/webauthn_credentials.html @@ -0,0 +1,30 @@ +{{define "webauthn_credentials"}} +
+{{if .WebAuthnCreds}} + + + + + + {{range .WebAuthnCreds}} + + + + + + + + {{end}} + +
NameCreatedLast UsedSign Count
{{.Name}}{{if .Discoverable}} passkey{{end}}{{formatTime .CreatedAt}}{{if .LastUsedAt}}{{formatTime (derefTime .LastUsedAt)}}{{else}}Never{{end}}{{.SignCount}} + +
+{{else}} +

No passkeys registered.

+{{end}} +
+{{end}} diff --git a/web/templates/fragments/webauthn_enroll.html b/web/templates/fragments/webauthn_enroll.html new file mode 100644 index 0000000..6f30b95 --- /dev/null +++ b/web/templates/fragments/webauthn_enroll.html @@ -0,0 +1,19 @@ +{{define "webauthn_enroll"}} +
+ + +
+ + +
+
+ + +
+
+ +
+
+{{end}} diff --git a/web/templates/login.html b/web/templates/login.html index b2ca4d4..5057fae 100644 --- a/web/templates/login.html +++ b/web/templates/login.html @@ -29,10 +29,24 @@
+ {{if .WebAuthnEnabled}} +
+
+
+ or +
+
+ + +
+ {{end}} +{{if .WebAuthnEnabled}}{{end}} {{end}} diff --git a/web/templates/profile.html b/web/templates/profile.html index 29868c1..aaa8558 100644 --- a/web/templates/profile.html +++ b/web/templates/profile.html @@ -4,6 +4,18 @@ +{{if .WebAuthnEnabled}} +
+

Passkeys

+

+ Passkeys let you sign in without a password using your device's biometrics or a security key. +

+ {{template "webauthn_credentials" .}} +

Add a Passkey

+ {{template "webauthn_enroll" .}} +
+ +{{end}}

Change Password