Add FIDO2/WebAuthn passkey authentication

Phase 14: Full WebAuthn support for passwordless passkey login and
hardware security key 2FA.

- go-webauthn/webauthn v0.16.1 dependency
- WebAuthnConfig with RPID/RPOrigin/DisplayName validation
- Migration 000009: webauthn_credentials table
- DB CRUD with ownership checks and admin operations
- internal/webauthn adapter: encrypt/decrypt at rest with AES-256-GCM
- REST: register begin/finish, login begin/finish, list, delete
- Web UI: profile enrollment, login passkey button, admin management
- gRPC: ListWebAuthnCredentials, RemoveWebAuthnCredential RPCs
- mciasdb: webauthn list/delete/reset subcommands
- OpenAPI: 6 new endpoints, WebAuthnCredentialInfo schema
- Policy: self-service enrollment rule, admin remove via wildcard
- Tests: DB CRUD, adapter round-trip, interface compliance
- Docs: ARCHITECTURE.md §22, PROJECT_PLAN.md Phase 14

Security: Credential IDs and public keys encrypted at rest with
AES-256-GCM via vault master key. Challenge ceremonies use 128-bit
nonces with 120s TTL in sync.Map. Sign counter validated on each
assertion to detect cloned authenticators. Password re-auth required
for registration (SEC-01 pattern). No credential material in API
responses or logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 16:12:59 -07:00
parent 19fa0c9a8e
commit 25417b24f4
42 changed files with 4214 additions and 84 deletions

View File

@@ -128,7 +128,8 @@ mciassrv (passphrase or keyfile) to decrypt secrets at rest.
**Human accounts** — interactive users. Can authenticate via: **Human accounts** — interactive users. Can authenticate via:
- Username + password (Argon2id hash stored in DB) - Username + password (Argon2id hash stored in DB)
- Optional TOTP (RFC 6238); if enrolled, required on every login - Optional TOTP (RFC 6238); if enrolled, required on every login
- Future: FIDO2/WebAuthn, Yubikey (not in scope for v1) - Optional FIDO2/WebAuthn passkeys and security keys; discoverable credentials
enable passwordless login, non-discoverable credentials serve as 2FA
**System accounts** — non-interactive service identities. Have: **System accounts** — non-interactive service identities. Have:
- A single active bearer token at a time (rotating the token revokes the old one) - A single active bearer token at a time (rotating the token revokes the old one)
@@ -420,6 +421,17 @@ value in an HTMX fragment or flash message.
| POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code | | POST | `/v1/auth/totp/confirm` | bearer JWT | Confirm TOTP enrollment with code |
| DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) | | DELETE | `/v1/auth/totp` | admin JWT | Remove TOTP from account (admin) |
### WebAuthn Endpoints
| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | `/v1/auth/webauthn/register/begin` | bearer JWT | Begin WebAuthn registration (requires password re-auth) |
| POST | `/v1/auth/webauthn/register/finish` | bearer JWT | Complete WebAuthn registration |
| POST | `/v1/auth/webauthn/login/begin` | none | Begin WebAuthn login (discoverable or username-scoped) |
| POST | `/v1/auth/webauthn/login/finish` | none | Complete WebAuthn login, returns JWT |
| GET | `/v1/accounts/{id}/webauthn` | admin JWT | List WebAuthn credential metadata |
| DELETE | `/v1/accounts/{id}/webauthn/{credentialId}` | admin JWT | Remove WebAuthn credential |
### Postgres Credential Endpoints ### Postgres Credential Endpoints
| Method | Path | Auth required | Description | | Method | Path | Auth required | Description |
@@ -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); CREATE INDEX idx_sa_delegates_grantee ON service_account_delegates (grantee_id);
``` ```
```sql
-- WebAuthn credentials (migration 000009)
CREATE TABLE webauthn_credentials (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
credential_id_enc BLOB NOT NULL,
credential_id_nonce BLOB NOT NULL,
public_key_enc BLOB NOT NULL,
public_key_nonce BLOB NOT NULL,
aaguid TEXT NOT NULL DEFAULT '',
sign_count INTEGER NOT NULL DEFAULT 0,
discoverable INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE INDEX idx_webauthn_credentials_account ON webauthn_credentials(account_id);
```
### Schema Notes ### Schema Notes
- Passwords are stored as PHC-format Argon2id strings (e.g., - Passwords are stored as PHC-format Argon2id strings (e.g.,
`$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm `$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>`), embedding algorithm
parameters. Future parameter upgrades are transparent. parameters. Future parameter upgrades are transparent.
- TOTP secrets and Postgres passwords are encrypted with AES-256-GCM using a - TOTP secrets, Postgres passwords, and WebAuthn credential IDs/public keys are
master key held only in server memory (derived at startup from a passphrase encrypted with AES-256-GCM using a master key held only in server memory
or keyfile). The nonce is stored adjacent to the ciphertext. (derived at startup from a passphrase or keyfile). The nonce is stored
adjacent to the ciphertext.
- The master key salt is stored in `server_config.master_key_salt` so the - The master key salt is stored in `server_config.master_key_salt` so the
Argon2id KDF produces the same key on every restart. Generated on first run. Argon2id KDF produces the same key on every restart. Generated on first run.
- The signing key encryption is layered: the Ed25519 private key is wrapped - The signing key encryption is layered: the Ed25519 private key is wrapped
@@ -782,7 +816,7 @@ mcias/
│ ├── config/ # config file parsing and validation │ ├── config/ # config file parsing and validation
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation │ ├── crypto/ # key management, AES-GCM helpers, master key derivation
│ ├── db/ # SQLite access layer (schema, migrations, queries) │ ├── db/ # SQLite access layer (schema, migrations, queries)
│ │ └── migrations/ # numbered SQL migrations (currently 8) │ │ └── migrations/ # numbered SQL migrations (currently 9)
│ ├── grpcserver/ # gRPC handler implementations │ ├── grpcserver/ # gRPC handler implementations
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy) │ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy)
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.) │ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
@@ -791,7 +825,8 @@ mcias/
│ ├── token/ # JWT issuance, validation, revocation │ ├── token/ # JWT issuance, validation, revocation
│ ├── ui/ # web UI context, CSRF, session, template handlers │ ├── ui/ # web UI context, CSRF, session, template handlers
│ ├── validate/ # input validation helpers (username, password strength) │ ├── validate/ # input validation helpers (username, password strength)
── vault/ # master key lifecycle: seal/unseal state, key derivation ── vault/ # master key lifecycle: seal/unseal state, key derivation
│ └── webauthn/ # FIDO2/WebAuthn adapter (encrypt/decrypt credentials, user interface)
├── web/ ├── web/
│ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build) │ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build)
│ ├── templates/ # HTML templates (base layout, pages, HTMX fragments) │ ├── templates/ # HTML templates (base layout, pages, HTMX fragments)
@@ -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_granted` | Admin granted a human account token-issue access for a system account |
| `token_delegate_revoked` | Admin revoked token-issue delegation | | `token_delegate_revoked` | Admin revoked token-issue delegation |
| `token_issued` | Token issued (existing event, also fires for delegate-issued tokens) | | `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.

View File

@@ -2,7 +2,29 @@
Source of truth for current development state. Source of truth for current development state.
--- ---
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean (pre-existing warnings only). Phases 014 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) ### 2026-03-16 — Documentation sync (ARCHITECTURE.md, PROJECT_PLAN.md)

View File

@@ -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 ## 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 34) → Phase 11 (interleaved with Phase 34)
→ Phase 12 (post Phase 3) → Phase 12 (post Phase 3)
→ Phase 13 (post Phase 3 and 11) → Phase 13 (post Phase 3 and 11)
→ Phase 14 (post v1.0.0)
``` ```
Each step must have passing tests before the next step begins. Each step must have passing tests before the next step begins.
All phases complete as of v1.0.0 (2026-03-15). Phases 013 complete as of v1.0.0 (2026-03-15).
Phase 14 complete as of 2026-03-16.

View File

@@ -13,7 +13,7 @@ import (
func (t *tool) runAccount(args []string) { func (t *tool) runAccount(args []string) {
if len(args) == 0 { if len(args) == 0 {
fatalf("account requires a subcommand: list, get, create, set-password, set-status, reset-totp") fatalf("account requires a subcommand: list, get, create, set-password, set-status, reset-totp, reset-webauthn")
} }
switch args[0] { switch args[0] {
case "list": case "list":
@@ -28,6 +28,8 @@ func (t *tool) runAccount(args []string) {
t.accountSetStatus(args[1:]) t.accountSetStatus(args[1:])
case "reset-totp": case "reset-totp":
t.accountResetTOTP(args[1:]) t.accountResetTOTP(args[1:])
case "reset-webauthn":
t.webauthnReset(args[1:])
default: default:
fatalf("unknown account subcommand %q", args[0]) fatalf("unknown account subcommand %q", args[0])
} }

View File

@@ -109,6 +109,8 @@ func main() {
tool.runAudit(subArgs) tool.runAudit(subArgs)
case "pgcreds": case "pgcreds":
tool.runPGCreds(subArgs) tool.runPGCreds(subArgs)
case "webauthn":
tool.runWebAuthn(subArgs)
case "rekey": case "rekey":
tool.runRekey(subArgs) tool.runRekey(subArgs)
default: default:
@@ -245,6 +247,11 @@ Commands:
account set-password --id UUID (prompts interactively) account set-password --id UUID (prompts interactively)
account set-status --id UUID --status active|inactive|deleted account set-status --id UUID --status active|inactive|deleted
account reset-totp --id UUID account reset-totp --id UUID
account reset-webauthn --id UUID
webauthn list --id UUID
webauthn delete --id UUID --credential-id N
webauthn reset --id UUID
role list --id UUID role list --id UUID
role grant --id UUID --role ROLE role grant --id UUID --role ROLE

121
cmd/mciasdb/webauthn.go Normal file
View File

@@ -0,0 +1,121 @@
package main
import (
"flag"
"fmt"
"os"
"strings"
)
func (t *tool) runWebAuthn(args []string) {
if len(args) == 0 {
fatalf("webauthn requires a subcommand: list, delete, reset")
}
switch args[0] {
case "list":
t.webauthnList(args[1:])
case "delete":
t.webauthnDelete(args[1:])
case "reset":
t.webauthnReset(args[1:])
default:
fatalf("unknown webauthn subcommand %q", args[0])
}
}
func (t *tool) webauthnList(args []string) {
fs := flag.NewFlagSet("webauthn list", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("webauthn list: --id is required")
}
a, err := t.db.GetAccountByUUID(*id)
if err != nil {
fatalf("get account: %v", err)
}
creds, err := t.db.GetWebAuthnCredentials(a.ID)
if err != nil {
fatalf("list webauthn credentials: %v", err)
}
if len(creds) == 0 {
fmt.Printf("No WebAuthn credentials for account %s\n", a.Username)
return
}
fmt.Printf("WebAuthn credentials for %s:\n\n", a.Username)
fmt.Printf("%-6s %-20s %-12s %-8s %-20s %-20s\n",
"ID", "NAME", "DISCOVERABLE", "COUNT", "CREATED", "LAST USED")
fmt.Println(strings.Repeat("-", 96))
for _, c := range creds {
disc := "no"
if c.Discoverable {
disc = "yes"
}
lastUsed := "never"
if c.LastUsedAt != nil {
lastUsed = c.LastUsedAt.UTC().Format("2006-01-02 15:04:05")
}
fmt.Printf("%-6d %-20s %-12s %-8d %-20s %-20s\n",
c.ID, c.Name, disc, c.SignCount,
c.CreatedAt.UTC().Format("2006-01-02 15:04:05"), lastUsed)
}
}
func (t *tool) webauthnDelete(args []string) {
fs := flag.NewFlagSet("webauthn delete", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
credID := fs.Int64("credential-id", 0, "credential DB row ID (required)")
_ = fs.Parse(args)
if *id == "" || *credID == 0 {
fatalf("webauthn delete: --id and --credential-id are required")
}
a, err := t.db.GetAccountByUUID(*id)
if err != nil {
fatalf("get account: %v", err)
}
if err := t.db.DeleteWebAuthnCredential(*credID, a.ID); err != nil {
fatalf("delete webauthn credential: %v", err)
}
if err := t.db.WriteAuditEvent("webauthn_removed", nil, &a.ID, "",
fmt.Sprintf(`{"actor":"mciasdb","credential_id":%d}`, *credID)); err != nil {
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
}
fmt.Printf("WebAuthn credential %d deleted from account %s\n", *credID, a.Username)
}
func (t *tool) webauthnReset(args []string) {
fs := flag.NewFlagSet("webauthn reset", flag.ExitOnError)
id := fs.String("id", "", "account UUID (required)")
_ = fs.Parse(args)
if *id == "" {
fatalf("webauthn reset: --id is required")
}
a, err := t.db.GetAccountByUUID(*id)
if err != nil {
fatalf("get account: %v", err)
}
count, err := t.db.DeleteAllWebAuthnCredentials(a.ID)
if err != nil {
fatalf("delete all webauthn credentials: %v", err)
}
if err := t.db.WriteAuditEvent("webauthn_removed", nil, &a.ID, "",
fmt.Sprintf(`{"actor":"mciasdb","action":"reset_webauthn","count":%d}`, count)); err != nil {
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
}
fmt.Printf("Removed %d WebAuthn credential(s) from account %s\n", count, a.Username)
}

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v6.33.4 // protoc v3.20.3
// source: mcias/v1/account.proto // source: mcias/v1/account.proto
package mciasv1 package mciasv1

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v6.33.4 // - protoc v3.20.3
// source: mcias/v1/account.proto // source: mcias/v1/account.proto
package mciasv1 package mciasv1

View File

@@ -569,6 +569,288 @@ func (*RemoveTOTPResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{11} return file_mcias_v1_auth_proto_rawDescGZIP(), []int{11}
} }
// ListWebAuthnCredentialsRequest lists metadata for an account's WebAuthn credentials.
type ListWebAuthnCredentialsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` // UUID
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListWebAuthnCredentialsRequest) Reset() {
*x = ListWebAuthnCredentialsRequest{}
mi := &file_mcias_v1_auth_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListWebAuthnCredentialsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListWebAuthnCredentialsRequest) ProtoMessage() {}
func (x *ListWebAuthnCredentialsRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_auth_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListWebAuthnCredentialsRequest.ProtoReflect.Descriptor instead.
func (*ListWebAuthnCredentialsRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{12}
}
func (x *ListWebAuthnCredentialsRequest) GetAccountId() string {
if x != nil {
return x.AccountId
}
return ""
}
// WebAuthnCredentialInfo holds metadata about a stored WebAuthn credential.
// Credential material (IDs, public keys) is never included.
type WebAuthnCredentialInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Aaguid string `protobuf:"bytes,3,opt,name=aaguid,proto3" json:"aaguid,omitempty"`
SignCount uint32 `protobuf:"varint,4,opt,name=sign_count,json=signCount,proto3" json:"sign_count,omitempty"`
Discoverable bool `protobuf:"varint,5,opt,name=discoverable,proto3" json:"discoverable,omitempty"`
Transports string `protobuf:"bytes,6,opt,name=transports,proto3" json:"transports,omitempty"`
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *WebAuthnCredentialInfo) Reset() {
*x = WebAuthnCredentialInfo{}
mi := &file_mcias_v1_auth_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *WebAuthnCredentialInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WebAuthnCredentialInfo) ProtoMessage() {}
func (x *WebAuthnCredentialInfo) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_auth_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use WebAuthnCredentialInfo.ProtoReflect.Descriptor instead.
func (*WebAuthnCredentialInfo) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{13}
}
func (x *WebAuthnCredentialInfo) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
func (x *WebAuthnCredentialInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *WebAuthnCredentialInfo) GetAaguid() string {
if x != nil {
return x.Aaguid
}
return ""
}
func (x *WebAuthnCredentialInfo) GetSignCount() uint32 {
if x != nil {
return x.SignCount
}
return 0
}
func (x *WebAuthnCredentialInfo) GetDiscoverable() bool {
if x != nil {
return x.Discoverable
}
return false
}
func (x *WebAuthnCredentialInfo) GetTransports() string {
if x != nil {
return x.Transports
}
return ""
}
func (x *WebAuthnCredentialInfo) GetCreatedAt() *timestamppb.Timestamp {
if x != nil {
return x.CreatedAt
}
return nil
}
func (x *WebAuthnCredentialInfo) GetLastUsedAt() *timestamppb.Timestamp {
if x != nil {
return x.LastUsedAt
}
return nil
}
// ListWebAuthnCredentialsResponse returns credential metadata.
type ListWebAuthnCredentialsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Credentials []*WebAuthnCredentialInfo `protobuf:"bytes,1,rep,name=credentials,proto3" json:"credentials,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListWebAuthnCredentialsResponse) Reset() {
*x = ListWebAuthnCredentialsResponse{}
mi := &file_mcias_v1_auth_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListWebAuthnCredentialsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListWebAuthnCredentialsResponse) ProtoMessage() {}
func (x *ListWebAuthnCredentialsResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_auth_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListWebAuthnCredentialsResponse.ProtoReflect.Descriptor instead.
func (*ListWebAuthnCredentialsResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{14}
}
func (x *ListWebAuthnCredentialsResponse) GetCredentials() []*WebAuthnCredentialInfo {
if x != nil {
return x.Credentials
}
return nil
}
// RemoveWebAuthnCredentialRequest removes a specific WebAuthn credential (admin).
type RemoveWebAuthnCredentialRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` // UUID
CredentialId int64 `protobuf:"varint,2,opt,name=credential_id,json=credentialId,proto3" json:"credential_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveWebAuthnCredentialRequest) Reset() {
*x = RemoveWebAuthnCredentialRequest{}
mi := &file_mcias_v1_auth_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RemoveWebAuthnCredentialRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RemoveWebAuthnCredentialRequest) ProtoMessage() {}
func (x *RemoveWebAuthnCredentialRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_auth_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RemoveWebAuthnCredentialRequest.ProtoReflect.Descriptor instead.
func (*RemoveWebAuthnCredentialRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{15}
}
func (x *RemoveWebAuthnCredentialRequest) GetAccountId() string {
if x != nil {
return x.AccountId
}
return ""
}
func (x *RemoveWebAuthnCredentialRequest) GetCredentialId() int64 {
if x != nil {
return x.CredentialId
}
return 0
}
// RemoveWebAuthnCredentialResponse confirms removal.
type RemoveWebAuthnCredentialResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveWebAuthnCredentialResponse) Reset() {
*x = RemoveWebAuthnCredentialResponse{}
mi := &file_mcias_v1_auth_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RemoveWebAuthnCredentialResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RemoveWebAuthnCredentialResponse) ProtoMessage() {}
func (x *RemoveWebAuthnCredentialResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_auth_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RemoveWebAuthnCredentialResponse.ProtoReflect.Descriptor instead.
func (*RemoveWebAuthnCredentialResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_auth_proto_rawDescGZIP(), []int{16}
}
var File_mcias_v1_auth_proto protoreflect.FileDescriptor var File_mcias_v1_auth_proto protoreflect.FileDescriptor
const file_mcias_v1_auth_proto_rawDesc = "" + const file_mcias_v1_auth_proto_rawDesc = "" +
@@ -601,7 +883,31 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"\x11RemoveTOTPRequest\x12\x1d\n" + "\x11RemoveTOTPRequest\x12\x1d\n" +
"\n" + "\n" +
"account_id\x18\x01 \x01(\tR\taccountId\"\x14\n" + "account_id\x18\x01 \x01(\tR\taccountId\"\x14\n" +
"\x12RemoveTOTPResponse2\xab\x03\n" + "\x12RemoveTOTPResponse\"?\n" +
"\x1eListWebAuthnCredentialsRequest\x12\x1d\n" +
"\n" +
"account_id\x18\x01 \x01(\tR\taccountId\"\xb0\x02\n" +
"\x16WebAuthnCredentialInfo\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
"\x06aaguid\x18\x03 \x01(\tR\x06aaguid\x12\x1d\n" +
"\n" +
"sign_count\x18\x04 \x01(\rR\tsignCount\x12\"\n" +
"\fdiscoverable\x18\x05 \x01(\bR\fdiscoverable\x12\x1e\n" +
"\n" +
"transports\x18\x06 \x01(\tR\n" +
"transports\x129\n" +
"\n" +
"created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +
"\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"lastUsedAt\"e\n" +
"\x1fListWebAuthnCredentialsResponse\x12B\n" +
"\vcredentials\x18\x01 \x03(\v2 .mcias.v1.WebAuthnCredentialInfoR\vcredentials\"e\n" +
"\x1fRemoveWebAuthnCredentialRequest\x12\x1d\n" +
"\n" +
"account_id\x18\x01 \x01(\tR\taccountId\x12#\n" +
"\rcredential_id\x18\x02 \x01(\x03R\fcredentialId\"\"\n" +
" RemoveWebAuthnCredentialResponse2\x8e\x05\n" +
"\vAuthService\x128\n" + "\vAuthService\x128\n" +
"\x05Login\x12\x16.mcias.v1.LoginRequest\x1a\x17.mcias.v1.LoginResponse\x12;\n" + "\x05Login\x12\x16.mcias.v1.LoginRequest\x1a\x17.mcias.v1.LoginResponse\x12;\n" +
"\x06Logout\x12\x17.mcias.v1.LogoutRequest\x1a\x18.mcias.v1.LogoutResponse\x12G\n" + "\x06Logout\x12\x17.mcias.v1.LogoutRequest\x1a\x18.mcias.v1.LogoutResponse\x12G\n" +
@@ -611,7 +917,9 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"EnrollTOTP\x12\x1b.mcias.v1.EnrollTOTPRequest\x1a\x1c.mcias.v1.EnrollTOTPResponse\x12J\n" + "EnrollTOTP\x12\x1b.mcias.v1.EnrollTOTPRequest\x1a\x1c.mcias.v1.EnrollTOTPResponse\x12J\n" +
"\vConfirmTOTP\x12\x1c.mcias.v1.ConfirmTOTPRequest\x1a\x1d.mcias.v1.ConfirmTOTPResponse\x12G\n" + "\vConfirmTOTP\x12\x1c.mcias.v1.ConfirmTOTPRequest\x1a\x1d.mcias.v1.ConfirmTOTPResponse\x12G\n" +
"\n" + "\n" +
"RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3" "RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponse\x12n\n" +
"\x17ListWebAuthnCredentials\x12(.mcias.v1.ListWebAuthnCredentialsRequest\x1a).mcias.v1.ListWebAuthnCredentialsResponse\x12q\n" +
"\x18RemoveWebAuthnCredential\x12).mcias.v1.RemoveWebAuthnCredentialRequest\x1a*.mcias.v1.RemoveWebAuthnCredentialResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_auth_proto_rawDescOnce sync.Once file_mcias_v1_auth_proto_rawDescOnce sync.Once
@@ -625,7 +933,7 @@ func file_mcias_v1_auth_proto_rawDescGZIP() []byte {
return file_mcias_v1_auth_proto_rawDescData return file_mcias_v1_auth_proto_rawDescData
} }
var file_mcias_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_mcias_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
var file_mcias_v1_auth_proto_goTypes = []any{ var file_mcias_v1_auth_proto_goTypes = []any{
(*LoginRequest)(nil), // 0: mcias.v1.LoginRequest (*LoginRequest)(nil), // 0: mcias.v1.LoginRequest
(*LoginResponse)(nil), // 1: mcias.v1.LoginResponse (*LoginResponse)(nil), // 1: mcias.v1.LoginResponse
@@ -639,28 +947,40 @@ var file_mcias_v1_auth_proto_goTypes = []any{
(*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse (*ConfirmTOTPResponse)(nil), // 9: mcias.v1.ConfirmTOTPResponse
(*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest (*RemoveTOTPRequest)(nil), // 10: mcias.v1.RemoveTOTPRequest
(*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse (*RemoveTOTPResponse)(nil), // 11: mcias.v1.RemoveTOTPResponse
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp (*ListWebAuthnCredentialsRequest)(nil), // 12: mcias.v1.ListWebAuthnCredentialsRequest
(*WebAuthnCredentialInfo)(nil), // 13: mcias.v1.WebAuthnCredentialInfo
(*ListWebAuthnCredentialsResponse)(nil), // 14: mcias.v1.ListWebAuthnCredentialsResponse
(*RemoveWebAuthnCredentialRequest)(nil), // 15: mcias.v1.RemoveWebAuthnCredentialRequest
(*RemoveWebAuthnCredentialResponse)(nil), // 16: mcias.v1.RemoveWebAuthnCredentialResponse
(*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp
} }
var file_mcias_v1_auth_proto_depIdxs = []int32{ var file_mcias_v1_auth_proto_depIdxs = []int32{
12, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp 17, // 0: mcias.v1.LoginResponse.expires_at:type_name -> google.protobuf.Timestamp
12, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp 17, // 1: mcias.v1.RenewTokenResponse.expires_at:type_name -> google.protobuf.Timestamp
0, // 2: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest 17, // 2: mcias.v1.WebAuthnCredentialInfo.created_at:type_name -> google.protobuf.Timestamp
2, // 3: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest 17, // 3: mcias.v1.WebAuthnCredentialInfo.last_used_at:type_name -> google.protobuf.Timestamp
4, // 4: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest 13, // 4: mcias.v1.ListWebAuthnCredentialsResponse.credentials:type_name -> mcias.v1.WebAuthnCredentialInfo
6, // 5: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest 0, // 5: mcias.v1.AuthService.Login:input_type -> mcias.v1.LoginRequest
8, // 6: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest 2, // 6: mcias.v1.AuthService.Logout:input_type -> mcias.v1.LogoutRequest
10, // 7: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest 4, // 7: mcias.v1.AuthService.RenewToken:input_type -> mcias.v1.RenewTokenRequest
1, // 8: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse 6, // 8: mcias.v1.AuthService.EnrollTOTP:input_type -> mcias.v1.EnrollTOTPRequest
3, // 9: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse 8, // 9: mcias.v1.AuthService.ConfirmTOTP:input_type -> mcias.v1.ConfirmTOTPRequest
5, // 10: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse 10, // 10: mcias.v1.AuthService.RemoveTOTP:input_type -> mcias.v1.RemoveTOTPRequest
7, // 11: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse 12, // 11: mcias.v1.AuthService.ListWebAuthnCredentials:input_type -> mcias.v1.ListWebAuthnCredentialsRequest
9, // 12: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse 15, // 12: mcias.v1.AuthService.RemoveWebAuthnCredential:input_type -> mcias.v1.RemoveWebAuthnCredentialRequest
11, // 13: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse 1, // 13: mcias.v1.AuthService.Login:output_type -> mcias.v1.LoginResponse
8, // [8:14] is the sub-list for method output_type 3, // 14: mcias.v1.AuthService.Logout:output_type -> mcias.v1.LogoutResponse
2, // [2:8] is the sub-list for method input_type 5, // 15: mcias.v1.AuthService.RenewToken:output_type -> mcias.v1.RenewTokenResponse
2, // [2:2] is the sub-list for extension type_name 7, // 16: mcias.v1.AuthService.EnrollTOTP:output_type -> mcias.v1.EnrollTOTPResponse
2, // [2:2] is the sub-list for extension extendee 9, // 17: mcias.v1.AuthService.ConfirmTOTP:output_type -> mcias.v1.ConfirmTOTPResponse
0, // [0:2] is the sub-list for field type_name 11, // 18: mcias.v1.AuthService.RemoveTOTP:output_type -> mcias.v1.RemoveTOTPResponse
14, // 19: mcias.v1.AuthService.ListWebAuthnCredentials:output_type -> mcias.v1.ListWebAuthnCredentialsResponse
16, // 20: mcias.v1.AuthService.RemoveWebAuthnCredential:output_type -> mcias.v1.RemoveWebAuthnCredentialResponse
13, // [13:21] is the sub-list for method output_type
5, // [5:13] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
} }
func init() { file_mcias_v1_auth_proto_init() } func init() { file_mcias_v1_auth_proto_init() }
@@ -674,7 +994,7 @@ func file_mcias_v1_auth_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_auth_proto_rawDesc), len(file_mcias_v1_auth_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 12, NumMessages: 17,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View File

@@ -27,6 +27,8 @@ const (
AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP" AuthService_EnrollTOTP_FullMethodName = "/mcias.v1.AuthService/EnrollTOTP"
AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP" AuthService_ConfirmTOTP_FullMethodName = "/mcias.v1.AuthService/ConfirmTOTP"
AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP" AuthService_RemoveTOTP_FullMethodName = "/mcias.v1.AuthService/RemoveTOTP"
AuthService_ListWebAuthnCredentials_FullMethodName = "/mcias.v1.AuthService/ListWebAuthnCredentials"
AuthService_RemoveWebAuthnCredential_FullMethodName = "/mcias.v1.AuthService/RemoveWebAuthnCredential"
) )
// AuthServiceClient is the client API for AuthService service. // AuthServiceClient is the client API for AuthService service.
@@ -53,6 +55,12 @@ type AuthServiceClient interface {
// RemoveTOTP removes TOTP from an account (admin only). // RemoveTOTP removes TOTP from an account (admin only).
// Requires: admin JWT in metadata. // Requires: admin JWT in metadata.
RemoveTOTP(ctx context.Context, in *RemoveTOTPRequest, opts ...grpc.CallOption) (*RemoveTOTPResponse, error) RemoveTOTP(ctx context.Context, in *RemoveTOTPRequest, opts ...grpc.CallOption) (*RemoveTOTPResponse, error)
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
// Requires: admin JWT in metadata.
ListWebAuthnCredentials(ctx context.Context, in *ListWebAuthnCredentialsRequest, opts ...grpc.CallOption) (*ListWebAuthnCredentialsResponse, error)
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
// Requires: admin JWT in metadata.
RemoveWebAuthnCredential(ctx context.Context, in *RemoveWebAuthnCredentialRequest, opts ...grpc.CallOption) (*RemoveWebAuthnCredentialResponse, error)
} }
type authServiceClient struct { type authServiceClient struct {
@@ -123,6 +131,26 @@ func (c *authServiceClient) RemoveTOTP(ctx context.Context, in *RemoveTOTPReques
return out, nil return out, nil
} }
func (c *authServiceClient) ListWebAuthnCredentials(ctx context.Context, in *ListWebAuthnCredentialsRequest, opts ...grpc.CallOption) (*ListWebAuthnCredentialsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListWebAuthnCredentialsResponse)
err := c.cc.Invoke(ctx, AuthService_ListWebAuthnCredentials_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) RemoveWebAuthnCredential(ctx context.Context, in *RemoveWebAuthnCredentialRequest, opts ...grpc.CallOption) (*RemoveWebAuthnCredentialResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveWebAuthnCredentialResponse)
err := c.cc.Invoke(ctx, AuthService_RemoveWebAuthnCredential_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AuthServiceServer is the server API for AuthService service. // AuthServiceServer is the server API for AuthService service.
// All implementations must embed UnimplementedAuthServiceServer // All implementations must embed UnimplementedAuthServiceServer
// for forward compatibility. // for forward compatibility.
@@ -147,6 +175,12 @@ type AuthServiceServer interface {
// RemoveTOTP removes TOTP from an account (admin only). // RemoveTOTP removes TOTP from an account (admin only).
// Requires: admin JWT in metadata. // Requires: admin JWT in metadata.
RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error)
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.
// Requires: admin JWT in metadata.
ListWebAuthnCredentials(context.Context, *ListWebAuthnCredentialsRequest) (*ListWebAuthnCredentialsResponse, error)
// RemoveWebAuthnCredential removes a specific WebAuthn credential.
// Requires: admin JWT in metadata.
RemoveWebAuthnCredential(context.Context, *RemoveWebAuthnCredentialRequest) (*RemoveWebAuthnCredentialResponse, error)
mustEmbedUnimplementedAuthServiceServer() mustEmbedUnimplementedAuthServiceServer()
} }
@@ -175,6 +209,12 @@ func (UnimplementedAuthServiceServer) ConfirmTOTP(context.Context, *ConfirmTOTPR
func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) { func (UnimplementedAuthServiceServer) RemoveTOTP(context.Context, *RemoveTOTPRequest) (*RemoveTOTPResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveTOTP not implemented") return nil, status.Error(codes.Unimplemented, "method RemoveTOTP not implemented")
} }
func (UnimplementedAuthServiceServer) ListWebAuthnCredentials(context.Context, *ListWebAuthnCredentialsRequest) (*ListWebAuthnCredentialsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListWebAuthnCredentials not implemented")
}
func (UnimplementedAuthServiceServer) RemoveWebAuthnCredential(context.Context, *RemoveWebAuthnCredentialRequest) (*RemoveWebAuthnCredentialResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveWebAuthnCredential not implemented")
}
func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {} func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {}
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {} func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
@@ -304,6 +344,42 @@ func _AuthService_RemoveTOTP_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _AuthService_ListWebAuthnCredentials_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListWebAuthnCredentialsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).ListWebAuthnCredentials(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_ListWebAuthnCredentials_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).ListWebAuthnCredentials(ctx, req.(*ListWebAuthnCredentialsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_RemoveWebAuthnCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveWebAuthnCredentialRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).RemoveWebAuthnCredential(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_RemoveWebAuthnCredential_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).RemoveWebAuthnCredential(ctx, req.(*RemoveWebAuthnCredentialRequest))
}
return interceptor(ctx, in, info, handler)
}
// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service. // AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -335,6 +411,14 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RemoveTOTP", MethodName: "RemoveTOTP",
Handler: _AuthService_RemoveTOTP_Handler, Handler: _AuthService_RemoveTOTP_Handler,
}, },
{
MethodName: "ListWebAuthnCredentials",
Handler: _AuthService_ListWebAuthnCredentials_Handler,
},
{
MethodName: "RemoveWebAuthnCredential",
Handler: _AuthService_RemoveWebAuthnCredential_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "mcias/v1/auth.proto", Metadata: "mcias/v1/auth.proto",

17
go.mod
View File

@@ -7,8 +7,8 @@ require (
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.49.0
golang.org/x/term v0.37.0 golang.org/x/term v0.41.0
google.golang.org/grpc v1.74.2 google.golang.org/grpc v1.74.2
google.golang.org/protobuf v1.36.7 google.golang.org/protobuf v1.36.7
modernc.org/sqlite v1.46.1 modernc.org/sqlite v1.46.1
@@ -16,13 +16,20 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/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/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/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/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

48
go.sum
View File

@@ -2,10 +2,18 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.16.1 h1:x5/SSki5/aIfogaRukqvbg/RXa3Sgxy/9vU7UfFPHKU=
github.com/go-webauthn/webauthn v0.16.1/go.mod h1:RBS+rtQJMkE5VfMQ4diDA2VNrEL8OeUhp4Srz37FHbQ=
github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk=
github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
@@ -14,6 +22,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -32,8 +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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
@@ -46,25 +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/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=

View File

@@ -8,18 +8,29 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
"strings"
"time" "time"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
) )
// Config is the top-level configuration structure parsed from the TOML file. // Config is the top-level configuration structure parsed from the TOML file.
type Config struct { type Config struct { //nolint:govet // fieldalignment: TOML section order is more readable
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
MasterKey MasterKeyConfig `toml:"master_key"` MasterKey MasterKeyConfig `toml:"master_key"`
Database DatabaseConfig `toml:"database"` Database DatabaseConfig `toml:"database"`
Tokens TokensConfig `toml:"tokens"` Tokens TokensConfig `toml:"tokens"`
Argon2 Argon2Config `toml:"argon2"` Argon2 Argon2Config `toml:"argon2"`
WebAuthn WebAuthnConfig `toml:"webauthn"`
}
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
// section disables WebAuthn support. If any field is set, RPID and RPOrigin are
// required and RPOrigin must use the HTTPS scheme.
type WebAuthnConfig struct {
RPID string `toml:"rp_id"`
RPOrigin string `toml:"rp_origin"`
DisplayName string `toml:"display_name"`
} }
// ServerConfig holds HTTP listener and TLS settings. // ServerConfig holds HTTP listener and TLS settings.
@@ -222,6 +233,19 @@ func (c *Config) validate() error {
errs = append(errs, errors.New("master_key: only one of passphrase_env or keyfile may be set")) errs = append(errs, errors.New("master_key: only one of passphrase_env or keyfile may be set"))
} }
// WebAuthn — if any field is set, RPID and RPOrigin are required.
hasWebAuthn := c.WebAuthn.RPID != "" || c.WebAuthn.RPOrigin != "" || c.WebAuthn.DisplayName != ""
if hasWebAuthn {
if c.WebAuthn.RPID == "" {
errs = append(errs, errors.New("webauthn.rp_id is required when webauthn is configured"))
}
if c.WebAuthn.RPOrigin == "" {
errs = append(errs, errors.New("webauthn.rp_origin is required when webauthn is configured"))
} else if !strings.HasPrefix(c.WebAuthn.RPOrigin, "https://") {
errs = append(errs, fmt.Errorf("webauthn.rp_origin must use the https:// scheme (got %q)", c.WebAuthn.RPOrigin))
}
}
return errors.Join(errs...) return errors.Join(errs...)
} }
@@ -233,3 +257,8 @@ func (c *Config) AdminExpiry() time.Duration { return c.Tokens.AdminExpiry.Durat
// ServiceExpiry returns the configured service token expiry duration. // ServiceExpiry returns the configured service token expiry duration.
func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.Duration } func (c *Config) ServiceExpiry() time.Duration { return c.Tokens.ServiceExpiry.Duration }
// WebAuthnEnabled reports whether WebAuthn/passkey support is configured.
func (c *Config) WebAuthnEnabled() bool {
return c.WebAuthn.RPID != "" && c.WebAuthn.RPOrigin != ""
}

View File

@@ -22,7 +22,7 @@ var migrationsFS embed.FS
// LatestSchemaVersion is the highest migration version defined in the // LatestSchemaVersion is the highest migration version defined in the
// migrations/ directory. Update this constant whenever a new migration file // migrations/ directory. Update this constant whenever a new migration file
// is added. // is added.
const LatestSchemaVersion = 7 const LatestSchemaVersion = 9
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL // newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
// files. It opens a dedicated *sql.DB using the same DSN as the main // files. It opens a dedicated *sql.DB using the same DSN as the main

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS webauthn_credentials;

View File

@@ -0,0 +1,18 @@
CREATE TABLE webauthn_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
credential_id_enc BLOB NOT NULL,
credential_id_nonce BLOB NOT NULL,
public_key_enc BLOB NOT NULL,
public_key_nonce BLOB NOT NULL,
aaguid TEXT NOT NULL DEFAULT '',
sign_count INTEGER NOT NULL DEFAULT 0,
discoverable INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE INDEX idx_webauthn_credentials_account_id ON webauthn_credentials(account_id);

208
internal/db/webauthn.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,92 @@
// WebAuthn gRPC handlers for listing and removing WebAuthn credentials.
// These are admin-only operations that mirror the REST handlers in
// internal/server/handlers_webauthn.go.
package grpcserver
import (
"context"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/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
}

View File

@@ -213,6 +213,11 @@ const (
EventTokenDelegateGranted = "token_delegate_granted" EventTokenDelegateGranted = "token_delegate_granted"
EventTokenDelegateRevoked = "token_delegate_revoked" EventTokenDelegateRevoked = "token_delegate_revoked"
EventWebAuthnEnrolled = "webauthn_enrolled"
EventWebAuthnRemoved = "webauthn_removed"
EventWebAuthnLoginOK = "webauthn_login_ok"
EventWebAuthnLoginFail = "webauthn_login_fail"
) )
// ServiceAccountDelegate records that a specific account has been granted // ServiceAccountDelegate records that a specific account has been granted
@@ -229,6 +234,26 @@ type ServiceAccountDelegate struct {
GranteeID int64 `json:"-"` GranteeID int64 `json:"-"`
} }
// WebAuthnCredential holds a stored WebAuthn/passkey credential.
// Credential IDs and public keys are encrypted at rest with AES-256-GCM;
// decrypted values must never be logged or included in API responses.
type WebAuthnCredential struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
Name string `json:"name"`
AAGUID string `json:"aaguid"`
Transports string `json:"transports,omitempty"`
CredentialIDEnc []byte `json:"-"`
CredentialIDNonce []byte `json:"-"`
PublicKeyEnc []byte `json:"-"`
PublicKeyNonce []byte `json:"-"`
ID int64 `json:"id"`
AccountID int64 `json:"-"`
SignCount uint32 `json:"sign_count"`
Discoverable bool `json:"discoverable"`
}
// PolicyRuleRecord is the database representation of a policy rule. // PolicyRuleRecord is the database representation of a policy rule.
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields). // RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
// The ID, Priority, and Description are stored as dedicated columns. // The ID, Priority, and Description are stored as dedicated columns.

View File

@@ -81,6 +81,16 @@ var defaultRules = []Rule{
OwnerMatchesSubject: true, OwnerMatchesSubject: true,
Effect: Allow, Effect: Allow,
}, },
{
// Self-service WebAuthn enrollment: any authenticated human account may
// register and manage their own passkeys/security keys. The handler
// verifies the subject matches before writing. Mirrors TOTP rule -3.
ID: -8,
Description: "Self-service: any principal may enroll their own WebAuthn credentials",
Priority: 0,
Actions: []Action{ActionEnrollWebAuthn},
Effect: Allow,
},
{ {
// Public endpoints: token validation and login do not require // Public endpoints: token validation and login do not require
// authentication. The middleware exempts them from RequireAuth entirely; // authentication. The middleware exempts them from RequireAuth entirely;

View File

@@ -48,6 +48,9 @@ const (
ActionListRules Action = "policy:list" ActionListRules Action = "policy:list"
ActionManageRules Action = "policy:manage" ActionManageRules Action = "policy:manage"
ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service
ActionRemoveWebAuthn Action = "webauthn:remove" // admin
) )
// ResourceType identifies what kind of object a request targets. // ResourceType identifies what kind of object a request targets.
@@ -60,6 +63,7 @@ const (
ResourceAuditLog ResourceType = "audit_log" ResourceAuditLog ResourceType = "audit_log"
ResourceTOTP ResourceType = "totp" ResourceTOTP ResourceType = "totp"
ResourcePolicy ResourceType = "policy" ResourcePolicy ResourceType = "policy"
ResourceWebAuthn ResourceType = "webauthn"
) )
// Effect is the outcome of policy evaluation. // Effect is the outcome of policy evaluation.

View File

@@ -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
}

View File

@@ -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/enroll", requireAuth(http.HandlerFunc(s.handleTOTPEnroll)))
mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm))) mux.Handle("POST /v1/auth/totp/confirm", requireAuth(http.HandlerFunc(s.handleTOTPConfirm)))
// WebAuthn registration endpoints (require valid token; self-service).
mux.Handle("POST /v1/auth/webauthn/register/begin", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterBegin)))
mux.Handle("POST /v1/auth/webauthn/register/finish", requireAuth(http.HandlerFunc(s.handleWebAuthnRegisterFinish)))
// WebAuthn login endpoints (public, rate-limited).
mux.Handle("POST /v1/auth/webauthn/login/begin", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginBegin)))
mux.Handle("POST /v1/auth/webauthn/login/finish", loginRateLimit(http.HandlerFunc(s.handleWebAuthnLoginFinish)))
// Policy-gated endpoints (formerly admin-only; now controlled by the engine). // Policy-gated endpoints (formerly admin-only; now controlled by the engine).
mux.Handle("DELETE /v1/auth/totp", mux.Handle("DELETE /v1/auth/totp",
requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove))) requirePolicy(policy.ActionRemoveTOTP, policy.ResourceTOTP, buildAcct)(http.HandlerFunc(s.handleTOTPRemove)))
@@ -338,6 +345,11 @@ func (s *Server) Handler() http.Handler {
requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds))) requirePolicy(policy.ActionReadPGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleGetPGCreds)))
mux.Handle("PUT /v1/accounts/{id}/pgcreds", mux.Handle("PUT /v1/accounts/{id}/pgcreds",
requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds))) requirePolicy(policy.ActionWritePGCreds, policy.ResourcePGCreds, buildAcct)(http.HandlerFunc(s.handleSetPGCreds)))
// WebAuthn credential management (policy-gated).
mux.Handle("GET /v1/accounts/{id}/webauthn",
requirePolicy(policy.ActionReadAccount, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleListWebAuthnCredentials)))
mux.Handle("DELETE /v1/accounts/{id}/webauthn/{credentialId}",
requirePolicy(policy.ActionRemoveWebAuthn, policy.ResourceWebAuthn, buildAcct)(http.HandlerFunc(s.handleDeleteWebAuthnCredential)))
mux.Handle("GET /v1/audit", mux.Handle("GET /v1/audit",
requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit))) requirePolicy(policy.ActionReadAudit, policy.ResourceAuditLog, nil)(http.HandlerFunc(s.handleListAudit)))
mux.Handle("GET /v1/accounts/{id}/tags", mux.Handle("GET /v1/accounts/{id}/tags",

View File

@@ -197,6 +197,15 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
} }
} }
// Load WebAuthn credentials for the account detail page.
var webAuthnCreds []*model.WebAuthnCredential
if u.cfg.WebAuthnEnabled() {
webAuthnCreds, err = u.db.GetWebAuthnCredentials(acct.ID)
if err != nil {
u.logger.Warn("load webauthn credentials", "error", err)
}
}
u.render(w, "account_detail", AccountDetailData{ u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)}, PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Account: acct, Account: acct,
@@ -211,6 +220,9 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
TokenDelegates: tokenDelegates, TokenDelegates: tokenDelegates,
DelegatableAccounts: delegatableAccounts, DelegatableAccounts: delegatableAccounts,
CanIssueToken: true, // account_detail is admin-only, so admin can always issue CanIssueToken: true, // account_detail is admin-only, so admin can always issue
WebAuthnCreds: webAuthnCreds,
DeletePrefix: "/accounts/" + acct.UUID + "/webauthn",
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
}) })
} }

View File

@@ -13,7 +13,9 @@ import (
// handleLoginPage renders the login form. // handleLoginPage renders the login form.
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) { func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
u.render(w, "login", LoginData{}) u.render(w, "login", LoginData{
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
})
} }
// handleLoginPost processes username+password (step 1) or TOTP code (step 2). // handleLoginPost processes username+password (step 1) or TOTP code (step 2).
@@ -290,13 +292,30 @@ func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, target
// handleProfilePage renders the profile page for the currently logged-in user. // handleProfilePage renders the profile page for the currently logged-in user.
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) { func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
csrfToken, _ := u.setCSRFCookies(w) csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "profile", ProfileData{ claims := claimsFromContext(r.Context())
data := ProfileData{
PageData: PageData{ PageData: PageData{
CSRFToken: csrfToken, CSRFToken: csrfToken,
ActorName: u.actorName(r), ActorName: u.actorName(r),
IsAdmin: isAdmin(r), IsAdmin: isAdmin(r),
}, },
}) WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
DeletePrefix: "/profile/webauthn",
}
// 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 // handleSelfChangePassword allows an authenticated human user to change their

View File

@@ -0,0 +1,696 @@
package ui
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-webauthn/webauthn/protocol"
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/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})
}

View File

@@ -177,6 +177,13 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
} }
return *actorID == *cred.OwnerID 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 }, "add": func(a, b int) int { return a + b },
"sub": 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 }, "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_reset_form.html",
"templates/fragments/password_change_form.html", "templates/fragments/password_change_form.html",
"templates/fragments/token_delegates.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...) base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil { if err != nil {
@@ -378,6 +387,9 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.HandleFunc("GET /login", u.handleLoginPage) uiMux.HandleFunc("GET /login", u.handleLoginPage)
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost))) uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
uiMux.HandleFunc("POST /logout", u.handleLogout) 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. // Protected routes.
// //
@@ -432,6 +444,12 @@ func (u *UIServer) Register(mux *http.ServeMux) {
// Profile routes — accessible to any authenticated user (not admin-only). // Profile routes — accessible to any authenticated user (not admin-only).
uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage))) uiMux.Handle("GET /profile", authed(http.HandlerFunc(u.handleProfilePage)))
uiMux.Handle("PUT /profile/password", authed(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword)))) 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 // 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 // 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 // a short-lived server-side nonce is issued after successful password
// verification, and only the nonce is embedded in the TOTP step form. // verification, and only the nonce is embedded in the TOTP step form.
Nonce string // single-use server-side nonce replacing the password hidden field 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. // 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. // 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 Account *model.Account
// PGCred is nil if none stored or the account is not a system account. // PGCred is nil if none stored or the account is not a system account.
PGCred *model.PGCredential PGCred *model.PGCredential
@@ -772,10 +792,15 @@ type AccountDetailData struct {
AllRoles []string AllRoles []string
Tags []string Tags []string
Tokens []*model.TokenRecord 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 // CanIssueToken is true when the viewing actor may issue tokens for this
// system account (admin role or explicit delegate grant). // system account (admin role or explicit delegate grant).
// Placed last to minimise GC scan area. // Placed last to minimise GC scan area.
CanIssueToken bool CanIssueToken bool
WebAuthnEnabled bool
} }
// ServiceAccountsData is the view model for the /service-accounts page. // 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. // 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 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. // PGCredsData is the view model for the "My PG Credentials" list page.

View File

@@ -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},
})
}

View File

@@ -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()))
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

37
internal/webauthn/user.go Normal file
View File

@@ -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 }

View File

@@ -86,6 +86,54 @@ components:
type: boolean type: boolean
description: Whether TOTP is enrolled and required for this account. description: Whether TOTP is enrolled and required for this account.
example: false 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: AuditEvent:
type: object type: object
@@ -847,6 +895,213 @@ paths:
"404": "404":
$ref: "#/components/responses/NotFound" $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: /v1/token/issue:
post: post:
summary: Issue service account token (admin) summary: Issue service account token (admin)

View File

@@ -75,6 +75,40 @@ message RemoveTOTPRequest {
// RemoveTOTPResponse confirms removal. // RemoveTOTPResponse confirms removal.
message RemoveTOTPResponse {} 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. // AuthService handles all authentication flows.
service AuthService { service AuthService {
// Login authenticates with username+password (+optional TOTP) and returns a JWT. // 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). // RemoveTOTP removes TOTP from an account (admin only).
// Requires: admin JWT in metadata. // Requires: admin JWT in metadata.
rpc RemoveTOTP(RemoveTOTPRequest) returns (RemoveTOTPResponse); 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);
} }

View File

@@ -86,6 +86,54 @@ components:
type: boolean type: boolean
description: Whether TOTP is enrolled and required for this account. description: Whether TOTP is enrolled and required for this account.
example: false 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: AuditEvent:
type: object type: object
@@ -847,6 +895,213 @@ paths:
"404": "404":
$ref: "#/components/responses/NotFound" $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: /v1/token/issue:
post: post:
summary: Issue service account token (admin) summary: Issue service account token (admin)

215
web/static/webauthn.js Normal file
View File

@@ -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);
});
});
}
});
})();

View File

@@ -15,6 +15,7 @@
<dt class="text-muted">Status</dt> <dt class="text-muted">Status</dt>
<dd id="status-cell">{{template "account_status" .}}</dd> <dd id="status-cell">{{template "account_status" .}}</dd>
<dt class="text-muted">TOTP</dt><dd>{{if .Account.TOTPRequired}}Enabled{{else}}Disabled{{end}}</dd> <dt class="text-muted">TOTP</dt><dd>{{if .Account.TOTPRequired}}Enabled{{else}}Disabled{{end}}</dd>
{{if .WebAuthnEnabled}}<dt class="text-muted">Passkeys</dt><dd>{{len .WebAuthnCreds}} registered</dd>{{end}}
<dt class="text-muted">Created</dt><dd class="text-small">{{formatTime .Account.CreatedAt}}</dd> <dt class="text-muted">Created</dt><dd class="text-small">{{formatTime .Account.CreatedAt}}</dd>
<dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .Account.UpdatedAt}}</dd> <dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .Account.UpdatedAt}}</dd>
</dl> </dl>
@@ -44,6 +45,12 @@
<div id="token-delegates-section">{{template "token_delegates" .}}</div> <div id="token-delegates-section">{{template "token_delegates" .}}</div>
</div> </div>
{{end}} {{end}}
{{if .WebAuthnEnabled}}
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Passkeys</h2>
{{template "webauthn_credentials" .}}
</div>
{{end}}
<div class="card"> <div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2> <h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
<div id="tags-editor">{{template "tags_editor" .}}</div> <div id="tags-editor">{{template "tags_editor" .}}</div>

View File

@@ -0,0 +1,30 @@
{{define "webauthn_credentials"}}
<div id="webauthn-credentials-section">
{{if .WebAuthnCreds}}
<table class="table" style="font-size:.85rem;margin-bottom:1rem">
<thead>
<tr><th>Name</th><th>Created</th><th>Last Used</th><th>Sign Count</th><th></th></tr>
</thead>
<tbody>
{{range .WebAuthnCreds}}
<tr>
<td>{{.Name}}{{if .Discoverable}} <span class="badge" title="Passkey (discoverable)">passkey</span>{{end}}</td>
<td class="text-small">{{formatTime .CreatedAt}}</td>
<td class="text-small">{{if .LastUsedAt}}{{formatTime (derefTime .LastUsedAt)}}{{else}}Never{{end}}</td>
<td>{{.SignCount}}</td>
<td>
<button class="btn btn-sm btn-danger"
hx-delete="{{$.DeletePrefix}}/{{.ID}}"
hx-target="#webauthn-credentials-section"
hx-swap="outerHTML"
hx-confirm="Remove this passkey?">Remove</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-muted text-small">No passkeys registered.</p>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,19 @@
{{define "webauthn_enroll"}}
<div id="webauthn-enroll-section">
<div id="webauthn-enroll-error" class="alert alert-error" style="display:none" role="alert"></div>
<div id="webauthn-enroll-success" class="alert alert-success" style="display:none" role="alert"></div>
<div class="form-group">
<label for="webauthn-name">Passkey Name</label>
<input class="form-control" type="text" id="webauthn-name" placeholder="e.g. YubiKey 5" value="Passkey">
</div>
<div class="form-group">
<label for="webauthn-password">Current Password</label>
<input class="form-control" type="password" id="webauthn-password" autocomplete="current-password">
</div>
<div class="form-actions">
<button class="btn btn-primary" type="button" id="webauthn-enroll-btn">
Add Passkey
</button>
</div>
</div>
{{end}}

View File

@@ -29,10 +29,24 @@
<button class="btn btn-primary" type="submit" style="width:100%">Sign in</button> <button class="btn btn-primary" type="submit" style="width:100%">Sign in</button>
</div> </div>
</form> </form>
{{if .WebAuthnEnabled}}
<div style="margin-top:1rem;text-align:center">
<div style="display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem">
<hr style="flex:1;border:0;border-top:1px solid #ddd">
<span class="text-muted text-small">or</span>
<hr style="flex:1;border:0;border-top:1px solid #ddd">
</div>
<div id="webauthn-login-error" class="alert alert-error" style="display:none" role="alert"></div>
<button class="btn btn-secondary" type="button" id="webauthn-login-btn" style="width:100%">
Sign in with passkey
</button>
</div>
{{end}}
</div> </div>
</div> </div>
</div> </div>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
{{if .WebAuthnEnabled}}<script src="/static/webauthn.js"></script>{{end}}
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@@ -4,6 +4,18 @@
<div class="page-header"> <div class="page-header">
<h1>Profile</h1> <h1>Profile</h1>
</div> </div>
{{if .WebAuthnEnabled}}
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Passkeys</h2>
<p class="text-muted text-small" style="margin-bottom:.75rem">
Passkeys let you sign in without a password using your device's biometrics or a security key.
</p>
{{template "webauthn_credentials" .}}
<h3 style="font-size:.9rem;font-weight:600;margin:1rem 0 .5rem">Add a Passkey</h3>
{{template "webauthn_enroll" .}}
</div>
<script src="/static/webauthn.js"></script>
{{end}}
<div class="card"> <div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Change Password</h2> <h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Change Password</h2>
<p class="text-muted text-small" style="margin-bottom:.75rem"> <p class="text-muted text-small" style="margin-bottom:.75rem">