diff --git a/.junie/memory/language.json b/.junie/memory/language.json index 811d100..0f62097 100644 --- a/.junie/memory/language.json +++ b/.junie/memory/language.json @@ -1 +1 @@ -[{"lang":"en","usageCount":6}] \ No newline at end of file +[{"lang":"en","usageCount":20}] \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8363e04..b3be617 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,9 +71,15 @@ internal/ policy/ Priority-based ACL engine engine/ Pluggable engine registry & interface ca/ CA (PKI) engine — X.509 certificate issuance - server/ HTTP server, routes, middleware + server/ REST API HTTP server, routes, middleware + grpcserver/ gRPC server, interceptors, per-service handlers + webserver/ Web UI HTTP server, routes, HTMX handlers +proto/metacrypt/ + v1/ Original gRPC proto definitions (generic Execute RPC) + v2/ Typed gRPC proto definitions (per-operation RPCs, Timestamp fields) +gen/metacrypt/v1/ Generated Go gRPC/protobuf code (v1) web/ - templates/ Go HTML templates (layout, init, unseal, login, dashboard) + templates/ Go HTML templates (layout, init, unseal, login, dashboard, PKI) static/ CSS, HTMX deploy/ Docker Compose, example configs ``` @@ -432,6 +438,9 @@ Passed as `config` at mount time: ## API Surface +Metacrypt exposes two API surfaces: a JSON REST API and a gRPC API. Both are +kept in sync — every operation available via REST has a corresponding gRPC RPC. + ### Seal/Unseal (Unauthenticated) | Method | Path | Description | Precondition | @@ -503,24 +512,96 @@ HTTP status codes: - `412` — service not initialized - `503` — service is sealed +### gRPC API + +Metacrypt also exposes a gRPC API defined in `proto/metacrypt/`. Two API +versions exist: + +#### v1 (current implementation) + +The v1 API uses a generic `Execute` RPC for all engine operations: + +```protobuf +rpc Execute(ExecuteRequest) returns (ExecuteResponse); + +message ExecuteRequest { + string mount = 1; + string operation = 2; + string path = 3; + google.protobuf.Struct data = 4; // JSON-like map +} + +message ExecuteResponse { + google.protobuf.Struct data = 1; // JSON-like map +} +``` + +Timestamps are represented as RFC3339 strings within the `Struct` payload. +The `EngineService` also provides `Mount`, `Unmount`, and `ListMounts` RPCs +for engine lifecycle management. + +#### v2 (defined, not yet implemented) + +The v2 API (`proto/metacrypt/v2/`) replaces the generic `Execute` RPC with +strongly-typed, per-operation RPCs and uses `google.protobuf.Timestamp` for +all time fields. Key changes: + +- **`CAService`**: 11 typed RPCs — `ImportRoot`, `GetRoot`, `CreateIssuer`, + `DeleteIssuer`, `ListIssuers`, `GetIssuer`, `GetChain`, `IssueCert`, + `GetCert`, `ListCerts`, `RenewCert`. +- **`EngineService`**: Retains `Mount`, `Unmount`, `ListMounts`; drops the + generic `Execute` RPC. `MountRequest.config` is `map` + instead of `google.protobuf.Struct`. +- **Timestamps**: All `issued_at` / `expires_at` fields use + `google.protobuf.Timestamp` instead of RFC3339 strings. +- **Message types**: `CertRecord` (full certificate data) and `CertSummary` + (lightweight, for list responses) replace the generic struct maps. +- **`ACMEService`** and **`AuthService`**: String timestamps replaced by + `google.protobuf.Timestamp`. + +The v2 proto definitions pass `buf lint` with no warnings. Server-side +implementation of v2 is planned as a future milestone. + +#### gRPC Interceptors (v1) + +The gRPC server (`internal/grpcserver/`) uses three interceptor maps to gate +access: + +| Interceptor map | Effect | +|-----------------------|-----------------------------------------------------| +| `sealRequiredMethods` | Returns `UNAVAILABLE` if the barrier is sealed | +| `authRequiredMethods` | Validates MCIAS bearer token; populates caller info | +| `adminRequiredMethods`| Requires `IsAdmin == true` on the caller | + +All three maps include the `Execute` RPC, ensuring engine operations are +always authenticated and gated on unseal state. + --- ## Web Interface Metacrypt includes an HTMX-powered web UI for basic operations: -| Route | Purpose | -|---------------|--------------------------------------------------| -| `/` | Redirects based on service state | -| `/init` | Password setup form (first-time only) | -| `/unseal` | Password entry to unseal | -| `/login` | MCIAS login form (username, password, TOTP) | -| `/dashboard` | Engine mounts, service state, admin controls | +| Route | Purpose | +|--------------------------|-------------------------------------------------------| +| `/` | Redirects based on service state | +| `/init` | Password setup form (first-time only) | +| `/unseal` | Password entry to unseal | +| `/login` | MCIAS login form (username, password, TOTP) | +| `/dashboard` | Engine mounts, service state, admin controls | +| `/pki` | PKI overview: list issuers, download CA/issuer PEMs | +| `/pki/issuer/{name}` | Issuer detail: certificates issued by that issuer | The dashboard shows mounted engines, the service state, and (for admins) a seal button. Templates use Go's `html/template` with a shared layout. HTMX provides form submission without full page reloads. +The PKI pages communicate with the backend via the internal gRPC client +(`internal/webserver/client.go`), which wraps the v1 gRPC `Execute` RPC. +The issuer detail page supports filtering certificates by common name +(case-insensitive substring match) and sorting by common name (default) or +expiry date. + --- ## Database Schema @@ -683,7 +764,9 @@ closing connections before exit. ### Planned Capabilities -- **gRPC API** — In addition to the JSON REST API (config field already reserved) +- **gRPC v2 server implementation** — The v2 typed proto definitions are + complete; the server-side handlers and generated Go code remain to be + implemented - **Post-quantum readiness** — Hybrid key exchange (ML-KEM + ECDH); the versioned ciphertext format and engine interface are designed for algorithm agility diff --git a/Dockerfile b/Dockerfile index 741fcf4..a22bc96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,36 +1,8 @@ -FROM golang:1.23-alpine AS builder - -RUN apk add --no-cache gcc musl-dev - -WORKDIR /build -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /metacrypt ./cmd/metacrypt -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /metacrypt-web ./cmd/metacrypt-web - -FROM alpine:3.21 - -RUN apk add --no-cache ca-certificates tzdata \ - && addgroup -S metacrypt \ - && adduser -S -G metacrypt -h /srv/metacrypt -s /sbin/nologin metacrypt \ - && mkdir -p /srv/metacrypt && chown metacrypt:metacrypt /srv/metacrypt - -COPY --from=builder /metacrypt /usr/local/bin/metacrypt -COPY --from=builder /metacrypt-web /usr/local/bin/metacrypt-web - -# /srv/metacrypt is the single volume mount point. -# It must contain: -# metacrypt.toml — configuration file -# certs/ — TLS certificate and key -# metacrypt.db — created automatically on first run -VOLUME /srv/metacrypt -WORKDIR /srv/metacrypt - -EXPOSE 8443 - -USER metacrypt - -ENTRYPOINT ["metacrypt"] -CMD ["server", "--config", "/srv/metacrypt/metacrypt.toml"] +# This file is retained for reference only. +# The API server and web UI are built as separate images: +# +# Dockerfile.api — metacrypt API server (ports 8443, 9443) +# Dockerfile.web — metacrypt web UI (port 8080) +# +# Use `make docker` or `docker compose` (deploy/docker/docker-compose.yml) +# to build both images. diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..d7b10fc --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,35 @@ +FROM golang:1.23-alpine AS builder + +RUN apk add --no-cache gcc musl-dev + +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /metacrypt ./cmd/metacrypt + +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S metacrypt \ + && adduser -S -G metacrypt -h /srv/metacrypt -s /sbin/nologin metacrypt \ + && mkdir -p /srv/metacrypt && chown metacrypt:metacrypt /srv/metacrypt + +COPY --from=builder /metacrypt /usr/local/bin/metacrypt + +# /srv/metacrypt is the single volume mount point. +# It must contain: +# metacrypt.toml — configuration file +# certs/ — TLS certificate and key +# metacrypt.db — created automatically on first run +VOLUME /srv/metacrypt +WORKDIR /srv/metacrypt + +EXPOSE 8443 +EXPOSE 9443 + +USER metacrypt + +ENTRYPOINT ["metacrypt"] +CMD ["server", "--config", "/srv/metacrypt/metacrypt.toml"] diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..912de07 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,33 @@ +FROM golang:1.23-alpine AS builder + +RUN apk add --no-cache gcc musl-dev + +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /metacrypt-web ./cmd/metacrypt-web + +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S metacrypt \ + && adduser -S -G metacrypt -h /srv/metacrypt -s /sbin/nologin metacrypt \ + && mkdir -p /srv/metacrypt && chown metacrypt:metacrypt /srv/metacrypt + +COPY --from=builder /metacrypt-web /usr/local/bin/metacrypt-web + +# /srv/metacrypt is the single volume mount point. +# It must contain: +# metacrypt.toml — configuration file +# certs/ — TLS certificate and key +VOLUME /srv/metacrypt +WORKDIR /srv/metacrypt + +EXPOSE 8080 + +USER metacrypt + +ENTRYPOINT ["metacrypt-web"] +CMD ["--config", "/srv/metacrypt/metacrypt.toml"] diff --git a/Makefile b/Makefile index 4690275..47fd55f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ -.PHONY: build test vet lint proto-lint clean docker all devserver metacrypt metacrypt-web proto +.PHONY: build test vet lint proto-lint clean docker all devserver metacrypt metacrypt-web proto binaries LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)" +binaries: metacrypt metacrypt-web + proto: protoc --go_out=. --go_opt=module=git.wntrmute.dev/kyle/metacrypt \ --go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/kyle/metacrypt \ @@ -33,7 +35,8 @@ clean: rm -f metacrypt metacrypt-web docker: - docker build -t metacrypt . + docker build -t metacrypt -f Dockerfile.api . + docker build -t metacrypt-web -f Dockerfile.web . docker-compose: docker compose -f deploy/docker/docker-compose.yml up --build diff --git a/cmd/metacrypt-web/main.go b/cmd/metacrypt-web/main.go index 7d3a3f8..b054822 100644 --- a/cmd/metacrypt-web/main.go +++ b/cmd/metacrypt-web/main.go @@ -31,7 +31,7 @@ func init() { } func run(cmd *cobra.Command, args []string) error { - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) configPath := cfgFile if configPath == "" { diff --git a/cmd/metacrypt/server.go b/cmd/metacrypt/server.go index 5c40537..9c4f4ad 100644 --- a/cmd/metacrypt/server.go +++ b/cmd/metacrypt/server.go @@ -95,18 +95,8 @@ func runServer(cmd *cobra.Command, args []string) error { } }() - if cfg.Server.GRPCAddr != "" { - go func() { - if err := srv.StartGRPC(); err != nil { - logger.Error("grpc server error", "error", err) - os.Exit(1) - } - }() - } - <-ctx.Done() logger.Info("shutting down") grpcSrv.Shutdown() - srv.ShutdownGRPC() return srv.Shutdown(context.Background()) } diff --git a/cmd/metacrypt/status.go b/cmd/metacrypt/status.go index c749df3..744a2ac 100644 --- a/cmd/metacrypt/status.go +++ b/cmd/metacrypt/status.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "strings" "github.com/spf13/cobra" ) @@ -49,7 +50,7 @@ func runStatus(cmd *cobra.Command, args []string) error { Transport: &http.Transport{TLSClientConfig: tlsCfg}, } - resp, err := client.Get(statusAddr + "/v1/status") + resp, err := client.Get(strings.TrimRight(statusAddr, "/") + "/v1/status") if err != nil { return err } diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index ea4b5b7..870c00a 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -2,7 +2,7 @@ services: metacrypt: build: context: ../.. - dockerfile: Dockerfile + dockerfile: Dockerfile.api container_name: metacrypt restart: unless-stopped ports: @@ -20,9 +20,8 @@ services: metacrypt-web: build: context: ../.. - dockerfile: Dockerfile + dockerfile: Dockerfile.web container_name: metacrypt-web - command: ["/usr/local/bin/metacrypt-web", "--config", "/srv/metacrypt/metacrypt.toml"] restart: unless-stopped ports: - "8080:8080" diff --git a/gen/metacrypt/v1/acme.pb.go b/gen/metacrypt/v1/acme.pb.go index 2cc94c9..2677da7 100644 --- a/gen/metacrypt/v1/acme.pb.go +++ b/gen/metacrypt/v1/acme.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v3.20.3 +// protoc v6.33.4 // source: proto/metacrypt/v1/acme.proto package metacryptv1 diff --git a/gen/metacrypt/v1/acme_grpc.pb.go b/gen/metacrypt/v1/acme_grpc.pb.go index 0e261f3..3f5885d 100644 --- a/gen/metacrypt/v1/acme_grpc.pb.go +++ b/gen/metacrypt/v1/acme_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v3.20.3 +// - protoc v6.33.4 // source: proto/metacrypt/v1/acme.proto package metacryptv1 diff --git a/gen/metacrypt/v1/auth.pb.go b/gen/metacrypt/v1/auth.pb.go index 5880729..037f71a 100644 --- a/gen/metacrypt/v1/auth.pb.go +++ b/gen/metacrypt/v1/auth.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v3.20.3 +// protoc v6.33.4 // source: proto/metacrypt/v1/auth.proto package metacryptv1 diff --git a/gen/metacrypt/v1/auth_grpc.pb.go b/gen/metacrypt/v1/auth_grpc.pb.go index ce4cce4..d501f73 100644 --- a/gen/metacrypt/v1/auth_grpc.pb.go +++ b/gen/metacrypt/v1/auth_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v3.20.3 +// - protoc v6.33.4 // source: proto/metacrypt/v1/auth.proto package metacryptv1 diff --git a/gen/metacrypt/v1/common.pb.go b/gen/metacrypt/v1/common.pb.go index 1e5cabc..2cb81ae 100644 --- a/gen/metacrypt/v1/common.pb.go +++ b/gen/metacrypt/v1/common.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v3.20.3 +// protoc v6.33.4 // source: proto/metacrypt/v1/common.proto package metacryptv1 diff --git a/gen/metacrypt/v1/engine.pb.go b/gen/metacrypt/v1/engine.pb.go index 835baea..60bee70 100644 --- a/gen/metacrypt/v1/engine.pb.go +++ b/gen/metacrypt/v1/engine.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v3.20.3 +// protoc v6.33.4 // source: proto/metacrypt/v1/engine.proto package metacryptv1 diff --git a/gen/metacrypt/v1/engine_grpc.pb.go b/gen/metacrypt/v1/engine_grpc.pb.go index 97341ed..c0ecffa 100644 --- a/gen/metacrypt/v1/engine_grpc.pb.go +++ b/gen/metacrypt/v1/engine_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v3.20.3 +// - protoc v6.33.4 // source: proto/metacrypt/v1/engine.proto package metacryptv1 diff --git a/gen/metacrypt/v1/pki.pb.go b/gen/metacrypt/v1/pki.pb.go index 7e78648..f3dd717 100644 --- a/gen/metacrypt/v1/pki.pb.go +++ b/gen/metacrypt/v1/pki.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v3.20.3 +// protoc v6.33.4 // source: proto/metacrypt/v1/pki.proto package metacryptv1 diff --git a/gen/metacrypt/v1/pki_grpc.pb.go b/gen/metacrypt/v1/pki_grpc.pb.go index 0c67149..2697a7c 100644 --- a/gen/metacrypt/v1/pki_grpc.pb.go +++ b/gen/metacrypt/v1/pki_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v3.20.3 +// - protoc v6.33.4 // source: proto/metacrypt/v1/pki.proto package metacryptv1 diff --git a/gen/metacrypt/v1/policy.pb.go b/gen/metacrypt/v1/policy.pb.go index f582d99..9173d2e 100644 --- a/gen/metacrypt/v1/policy.pb.go +++ b/gen/metacrypt/v1/policy.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v3.20.3 +// protoc v6.33.4 // source: proto/metacrypt/v1/policy.proto package metacryptv1 diff --git a/gen/metacrypt/v1/policy_grpc.pb.go b/gen/metacrypt/v1/policy_grpc.pb.go index fa0e3f9..9893c24 100644 --- a/gen/metacrypt/v1/policy_grpc.pb.go +++ b/gen/metacrypt/v1/policy_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v3.20.3 +// - protoc v6.33.4 // source: proto/metacrypt/v1/policy.proto package metacryptv1 diff --git a/gen/metacrypt/v1/system.pb.go b/gen/metacrypt/v1/system.pb.go index 85a66c6..6deaebf 100644 --- a/gen/metacrypt/v1/system.pb.go +++ b/gen/metacrypt/v1/system.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v3.20.3 +// protoc v6.33.4 // source: proto/metacrypt/v1/system.proto package metacryptv1 diff --git a/gen/metacrypt/v1/system_grpc.pb.go b/gen/metacrypt/v1/system_grpc.pb.go index 7c61b2b..704cd10 100644 --- a/gen/metacrypt/v1/system_grpc.pb.go +++ b/gen/metacrypt/v1/system_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 -// - protoc v3.20.3 +// - protoc v6.33.4 // source: proto/metacrypt/v1/system.proto package metacryptv1 diff --git a/internal/engine/ca/ca.go b/internal/engine/ca/ca.go index 1fb33eb..0935e2d 100644 --- a/internal/engine/ca/ca.go +++ b/internal/engine/ca/ca.go @@ -412,7 +412,7 @@ func (e *CAEngine) handleImportRoot(ctx context.Context, req *engine.Request) (* return &engine.Response{ Data: map[string]interface{}{ "cn": newCert.Subject.CommonName, - "expires_at": newCert.NotAfter, + "expires_at": newCert.NotAfter.Format(time.RFC3339), }, }, nil } @@ -639,7 +639,7 @@ func (e *CAEngine) handleListIssuers(_ context.Context, req *engine.Request) (*e return nil, ErrSealed } - names := make([]string, 0, len(e.issuers)) + names := make([]interface{}, 0, len(e.issuers)) for name := range e.issuers { names = append(names, name) } @@ -795,7 +795,7 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin "cn": cn, "sans": allSANs, "issued_by": req.CallerInfo.Username, - "expires_at": leafCert.NotAfter, + "expires_at": leafCert.NotAfter.Format(time.RFC3339), }, }, nil } @@ -838,8 +838,8 @@ func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*eng "profile": record.Profile, "cert_pem": record.CertPEM, "issued_by": record.IssuedBy, - "issued_at": record.IssuedAt, - "expires_at": record.ExpiresAt, + "issued_at": record.IssuedAt.Format(time.RFC3339), + "expires_at": record.ExpiresAt.Format(time.RFC3339), }, }, nil } @@ -857,7 +857,7 @@ func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*e return nil, fmt.Errorf("ca: list certs: %w", err) } - var certs []map[string]interface{} + var certs []interface{} for _, p := range paths { if !strings.HasSuffix(p, ".json") { continue @@ -876,8 +876,8 @@ func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*e "cn": record.CN, "profile": record.Profile, "issued_by": record.IssuedBy, - "issued_at": record.IssuedAt, - "expires_at": record.ExpiresAt, + "issued_at": record.IssuedAt.Format(time.RFC3339), + "expires_at": record.ExpiresAt.Format(time.RFC3339), }) } @@ -1009,7 +1009,7 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin "key_pem": string(newKeyPEM), "chain_pem": string(chainPEM), "cn": record.CN, - "expires_at": newCert.NotAfter, + "expires_at": newCert.NotAfter.Format(time.RFC3339), }, }, nil } diff --git a/internal/engine/ca/ca_test.go b/internal/engine/ca/ca_test.go index 22cdea2..6e63286 100644 --- a/internal/engine/ca/ca_test.go +++ b/internal/engine/ca/ca_test.go @@ -566,7 +566,7 @@ func TestGetAndListCerts(t *testing.T) { t.Fatalf("list-certs: %v", err) } - certs, ok := listResp.Data["certs"].([]map[string]interface{}) + certs, ok := listResp.Data["certs"].([]interface{}) if !ok { t.Fatalf("certs type: %T", listResp.Data["certs"]) } @@ -575,7 +575,7 @@ func TestGetAndListCerts(t *testing.T) { } // Get a specific cert. - serial := certs[0]["serial"].(string) //nolint:errcheck + serial := certs[0].(map[string]interface{})["serial"].(string) //nolint:errcheck getResp, err := eng.HandleRequest(ctx, &engine.Request{ Operation: "get-cert", CallerInfo: userCaller(), diff --git a/internal/grpcserver/engine.go b/internal/grpcserver/engine.go index f98c999..de78d78 100644 --- a/internal/grpcserver/engine.go +++ b/internal/grpcserver/engine.go @@ -90,6 +90,12 @@ func (es *engineServer) Execute(ctx context.Context, req *pb.ExecuteRequest) (*p } } + username := "" + if ti != nil { + username = ti.Username + } + es.s.logger.Info("grpc: engine execute", "mount", req.Mount, "operation", req.Operation, "username", username) + resp, err := es.s.engines.HandleRequest(ctx, req.Mount, engReq) if err != nil { st := codes.Internal @@ -101,8 +107,10 @@ func (es *engineServer) Execute(ctx context.Context, req *pb.ExecuteRequest) (*p case strings.Contains(err.Error(), "not found"): st = codes.NotFound } + es.s.logger.Error("grpc: engine execute failed", "mount", req.Mount, "operation", req.Operation, "username", username, "error", err) return nil, status.Error(st, err.Error()) } + es.s.logger.Info("grpc: engine execute ok", "mount", req.Mount, "operation", req.Operation, "username", username) pbData, err := structpb.NewStruct(resp.Data) if err != nil { diff --git a/internal/grpcserver/server.go b/internal/grpcserver/server.go index 9ac1cad..ae05b30 100644 --- a/internal/grpcserver/server.go +++ b/internal/grpcserver/server.go @@ -111,7 +111,7 @@ func sealRequiredMethods() map[string]bool { "/metacrypt.v1.EngineService/Mount": true, "/metacrypt.v1.EngineService/Unmount": true, "/metacrypt.v1.EngineService/ListMounts": true, - "/metacrypt.v1.EngineService/Request": true, + "/metacrypt.v1.EngineService/Execute": true, "/metacrypt.v1.PKIService/GetRootCert": true, "/metacrypt.v1.PKIService/GetChain": true, "/metacrypt.v1.PKIService/GetIssuerCert": true, @@ -134,7 +134,7 @@ func authRequiredMethods() map[string]bool { "/metacrypt.v1.EngineService/Mount": true, "/metacrypt.v1.EngineService/Unmount": true, "/metacrypt.v1.EngineService/ListMounts": true, - "/metacrypt.v1.EngineService/Request": true, + "/metacrypt.v1.EngineService/Execute": true, "/metacrypt.v1.PolicyService/CreatePolicy": true, "/metacrypt.v1.PolicyService/ListPolicies": true, "/metacrypt.v1.PolicyService/GetPolicy": true, diff --git a/internal/server/grpc.go b/internal/server/grpc.go deleted file mode 100644 index abf7999..0000000 --- a/internal/server/grpc.go +++ /dev/null @@ -1,126 +0,0 @@ -package server - -import ( - "context" - "crypto/tls" - "errors" - "fmt" - "net" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials" - grpcstatus "google.golang.org/grpc/status" - - metacryptv1 "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1" - "git.wntrmute.dev/kyle/metacrypt/internal/crypto" - "git.wntrmute.dev/kyle/metacrypt/internal/seal" -) - -// systemServiceServer implements metacryptv1.SystemServiceServer. -type systemServiceServer struct { - metacryptv1.UnimplementedSystemServiceServer - s *Server -} - -func (g *systemServiceServer) Status(_ context.Context, _ *metacryptv1.StatusRequest) (*metacryptv1.StatusResponse, error) { - return &metacryptv1.StatusResponse{State: g.s.seal.State().String()}, nil -} - -func (g *systemServiceServer) Init(ctx context.Context, req *metacryptv1.InitRequest) (*metacryptv1.InitResponse, error) { - params := crypto.Argon2Params{ - Time: g.s.cfg.Seal.Argon2Time, - Memory: g.s.cfg.Seal.Argon2Memory, - Threads: g.s.cfg.Seal.Argon2Threads, - } - if err := g.s.seal.Initialize(ctx, []byte(req.Password), params); err != nil { - if errors.Is(err, seal.ErrAlreadyInitialized) { - return nil, grpcstatus.Error(codes.AlreadyExists, "already initialized") - } - g.s.logger.Error("grpc init failed", "error", err) - return nil, grpcstatus.Error(codes.Internal, "initialization failed") - } - return &metacryptv1.InitResponse{State: g.s.seal.State().String()}, nil -} - -func (g *systemServiceServer) Unseal(ctx context.Context, req *metacryptv1.UnsealRequest) (*metacryptv1.UnsealResponse, error) { - if err := g.s.seal.Unseal([]byte(req.Password)); err != nil { - switch { - case errors.Is(err, seal.ErrNotInitialized): - return nil, grpcstatus.Error(codes.FailedPrecondition, "not initialized") - case errors.Is(err, seal.ErrInvalidPassword): - return nil, grpcstatus.Error(codes.Unauthenticated, "invalid password") - case errors.Is(err, seal.ErrRateLimited): - return nil, grpcstatus.Error(codes.ResourceExhausted, "too many attempts, try again later") - case errors.Is(err, seal.ErrNotSealed): - return nil, grpcstatus.Error(codes.AlreadyExists, "already unsealed") - default: - g.s.logger.Error("grpc unseal failed", "error", err) - return nil, grpcstatus.Error(codes.Internal, "unseal failed") - } - } - - if err := g.s.engines.UnsealAll(ctx); err != nil { - g.s.logger.Error("grpc engine unseal failed", "error", err) - return nil, grpcstatus.Error(codes.Internal, "engine unseal failed") - } - - return &metacryptv1.UnsealResponse{State: g.s.seal.State().String()}, nil -} - -func (g *systemServiceServer) Seal(_ context.Context, _ *metacryptv1.SealRequest) (*metacryptv1.SealResponse, error) { - if err := g.s.engines.SealAll(); err != nil { - g.s.logger.Error("grpc seal engines failed", "error", err) - } - if err := g.s.seal.Seal(); err != nil { - g.s.logger.Error("grpc seal failed", "error", err) - return nil, grpcstatus.Error(codes.Internal, "seal failed") - } - g.s.auth.ClearCache() - return &metacryptv1.SealResponse{State: g.s.seal.State().String()}, nil -} - -// StartGRPC starts the gRPC server on cfg.Server.GRPCAddr using the same TLS -// certificate as the HTTP server. It blocks until the listener closes. -func (s *Server) StartGRPC() error { - if s.cfg.Server.GRPCAddr == "" { - return nil - } - - cert, err := tls.LoadX509KeyPair(s.cfg.Server.TLSCert, s.cfg.Server.TLSKey) - if err != nil { - return fmt.Errorf("grpc: load TLS key pair: %w", err) - } - tlsCfg := &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, - CipherSuites: []uint16{ - tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, - tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, - }, - } - - grpcSrv := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsCfg))) - metacryptv1.RegisterSystemServiceServer(grpcSrv, &systemServiceServer{s: s}) - - lis, err := net.Listen("tcp", s.cfg.Server.GRPCAddr) - if err != nil { - return fmt.Errorf("grpc: listen: %w", err) - } - - s.grpcSrv = grpcSrv - s.logger.Info("starting gRPC server", "addr", s.cfg.Server.GRPCAddr) - if err := grpcSrv.Serve(lis); err != nil { - return fmt.Errorf("grpc: serve: %w", err) - } - return nil -} - -// ShutdownGRPC gracefully stops the gRPC server. -func (s *Server) ShutdownGRPC() { - if s.grpcSrv != nil { - s.grpcSrv.GracefulStop() - } -} diff --git a/internal/webserver/client.go b/internal/webserver/client.go index 8bb5f78..cc3e78e 100644 --- a/internal/webserver/client.go +++ b/internal/webserver/client.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "crypto/x509" "fmt" + "log/slog" "os" "google.golang.org/grpc" @@ -24,9 +25,12 @@ type VaultClient struct { } // NewVaultClient dials the vault gRPC server and returns a client. -func NewVaultClient(addr, caCertPath string) (*VaultClient, error) { +func NewVaultClient(addr, caCertPath string, logger *slog.Logger) (*VaultClient, error) { + logger.Debug("connecting to vault", "addr", addr, "ca_cert", caCertPath) + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} if caCertPath != "" { + logger.Debug("loading vault CA certificate", "path", caCertPath) pemData, err := os.ReadFile(caCertPath) //nolint:gosec if err != nil { return nil, fmt.Errorf("webserver: read CA cert: %w", err) @@ -36,12 +40,17 @@ func NewVaultClient(addr, caCertPath string) (*VaultClient, error) { return nil, fmt.Errorf("webserver: parse CA cert") } tlsCfg.RootCAs = pool + logger.Debug("vault CA certificate loaded successfully") + } else { + logger.Debug("no CA cert configured, using system roots") } + logger.Debug("dialing vault gRPC", "addr", addr) conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg))) if err != nil { return nil, fmt.Errorf("webserver: dial vault: %w", err) } + logger.Debug("vault gRPC connection established", "addr", addr) return &VaultClient{ conn: conn, diff --git a/internal/webserver/routes.go b/internal/webserver/routes.go index 91f1dc7..fe5af22 100644 --- a/internal/webserver/routes.go +++ b/internal/webserver/routes.go @@ -12,6 +12,17 @@ import ( "google.golang.org/grpc/status" ) +// splitLines splits a newline-delimited string into non-empty trimmed lines. +func splitLines(s string) []interface{} { + var out []interface{} + for _, line := range strings.Split(s, "\n") { + if v := strings.TrimSpace(line); v != "" { + out = append(out, v) + } + } + return out +} + func (ws *WebServer) registerRoutes(r chi.Router) { r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(ws.staticFS)))) @@ -26,6 +37,8 @@ func (ws *WebServer) registerRoutes(r chi.Router) { r.Get("/", ws.requireAuth(ws.handlePKI)) r.Post("/import-root", ws.requireAuth(ws.handleImportRoot)) r.Post("/create-issuer", ws.requireAuth(ws.handleCreateIssuer)) + r.Post("/issue", ws.requireAuth(ws.handleIssueCert)) + r.Get("/issuer/{issuer}", ws.requireAuth(ws.handleIssuerDetail)) r.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer)) }) } @@ -394,7 +407,159 @@ func (ws *WebServer) handlePKIIssuer(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(certPEM) //nolint:gosec } +func (ws *WebServer) handleIssuerDetail(w http.ResponseWriter, r *http.Request) { + info := tokenInfoFromContext(r.Context()) + token := extractCookie(r) + + mountName, err := ws.findCAMount(r, token) + if err != nil { + http.Error(w, "no CA engine mounted", http.StatusNotFound) + return + } + + issuerName := chi.URLParam(r, "issuer") + + resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-certs", nil) + if err != nil { + http.Error(w, "failed to list certificates", http.StatusInternalServerError) + return + } + + nameFilter := strings.ToLower(r.URL.Query().Get("name")) + sortBy := r.URL.Query().Get("sort") + if sortBy == "" { + sortBy = "cn" + } + + var certs []map[string]interface{} + if raw, ok := resp["certs"]; ok { + if list, ok := raw.([]interface{}); ok { + for _, item := range list { + if m, ok := item.(map[string]interface{}); ok { + issuer, _ := m["issuer"].(string) + if issuer != issuerName { + continue + } + if nameFilter != "" { + cn, _ := m["cn"].(string) + if !strings.Contains(strings.ToLower(cn), nameFilter) { + continue + } + } + certs = append(certs, m) + } + } + } + } + + // Sort: by expiry date or by common name (default). + if sortBy == "expiry" { + for i := 1; i < len(certs); i++ { + for j := i; j > 0; j-- { + a, _ := certs[j-1]["expires_at"].(string) + b, _ := certs[j]["expires_at"].(string) + if a > b { + certs[j-1], certs[j] = certs[j], certs[j-1] + } + } + } + } else { + for i := 1; i < len(certs); i++ { + for j := i; j > 0; j-- { + a, _ := certs[j-1]["cn"].(string) + b, _ := certs[j]["cn"].(string) + if strings.ToLower(a) > strings.ToLower(b) { + certs[j-1], certs[j] = certs[j], certs[j-1] + } + } + } + } + + data := map[string]interface{}{ + "Username": info.Username, + "IsAdmin": info.IsAdmin, + "MountName": mountName, + "IssuerName": issuerName, + "Certs": certs, + "NameFilter": r.URL.Query().Get("name"), + "SortBy": sortBy, + } + + ws.renderTemplate(w, "issuer_detail.html", data) +} + +func (ws *WebServer) handleIssueCert(w http.ResponseWriter, r *http.Request) { + info := tokenInfoFromContext(r.Context()) + token := extractCookie(r) + + mountName, err := ws.findCAMount(r, token) + if err != nil { + http.Error(w, "no CA engine mounted", http.StatusNotFound) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + _ = r.ParseForm() + + commonName := r.FormValue("common_name") + if commonName == "" { + ws.renderPKIWithError(w, r, mountName, info, "Common name is required") + return + } + issuer := r.FormValue("issuer") + if issuer == "" { + ws.renderPKIWithError(w, r, mountName, info, "Issuer is required") + return + } + + reqData := map[string]interface{}{ + "common_name": commonName, + "issuer": issuer, + } + if v := r.FormValue("profile"); v != "" { + reqData["profile"] = v + } + if v := r.FormValue("ttl"); v != "" { + reqData["ttl"] = v + } + if lines := splitLines(r.FormValue("dns_names")); len(lines) > 0 { + reqData["dns_names"] = lines + } + if lines := splitLines(r.FormValue("ip_addresses")); len(lines) > 0 { + reqData["ip_addresses"] = lines + } + + resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "issue", reqData) + if err != nil { + ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err)) + return + } + + // Re-render the PKI page with the issued certificate displayed. + data := map[string]interface{}{ + "Username": info.Username, + "IsAdmin": info.IsAdmin, + "MountName": mountName, + "IssuedCert": resp, + } + if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 { + if cert, err := parsePEMCert(rootPEM); err == nil { + data["RootCN"] = cert.Subject.CommonName + data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ") + data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339) + data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339) + data["RootExpired"] = time.Now().After(cert.NotAfter) + data["HasRoot"] = true + } + } + if issuerResp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil { + data["Issuers"] = issuerResp["issuers"] + } + ws.renderTemplate(w, "pki.html", data) +} + func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) { + token := extractCookie(r) data := map[string]interface{}{ "Username": info.Username, "IsAdmin": info.IsAdmin, @@ -412,6 +577,9 @@ func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, data["HasRoot"] = true } } + if resp, err := ws.vault.EngineRequest(r.Context(), token, mountName, "list-issuers", nil); err == nil { + data["Issuers"] = resp["issuers"] + } ws.renderTemplate(w, "pki.html", data) } diff --git a/internal/webserver/server.go b/internal/webserver/server.go index 11c3fc8..ec79a3a 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -29,10 +29,12 @@ type WebServer struct { // New creates a new WebServer. It dials the vault gRPC endpoint. func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) { - vault, err := NewVaultClient(cfg.Web.VaultGRPC, cfg.Web.VaultCACert) + logger.Info("connecting to vault", "addr", cfg.Web.VaultGRPC, "ca_cert", cfg.Web.VaultCACert) + vault, err := NewVaultClient(cfg.Web.VaultGRPC, cfg.Web.VaultCACert, logger) if err != nil { return nil, fmt.Errorf("webserver: connect to vault: %w", err) } + logger.Info("vault connection ready", "addr", cfg.Web.VaultGRPC) staticFS, err := fs.Sub(webui.FS, "static") if err != nil { @@ -47,9 +49,37 @@ func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) { }, nil } +// loggingMiddleware logs each incoming HTTP request. +func (ws *WebServer) loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lw := &loggingResponseWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(lw, r) + ws.logger.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", lw.status, + "duration", time.Since(start), + "remote_addr", r.RemoteAddr, + ) + }) +} + +// loggingResponseWriter wraps http.ResponseWriter to capture the status code. +type loggingResponseWriter struct { + http.ResponseWriter + status int +} + +func (lw *loggingResponseWriter) WriteHeader(code int) { + lw.status = code + lw.ResponseWriter.WriteHeader(code) +} + // Start starts the web server. It blocks until the server is closed. func (ws *WebServer) Start() error { r := chi.NewRouter() + r.Use(ws.loggingMiddleware) ws.registerRoutes(r) ws.httpSrv = &http.Server{ diff --git a/proto/metacrypt/v2/acme.proto b/proto/metacrypt/v2/acme.proto new file mode 100644 index 0000000..3d00bda --- /dev/null +++ b/proto/metacrypt/v2/acme.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +package metacrypt.v2; + +import "google/protobuf/timestamp.proto"; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2"; + +// ACMEService provides authenticated management of ACME state. +// These RPCs correspond to the REST management endpoints at /v2/acme/{mount}/. +// The ACME protocol endpoints themselves (/acme/{mount}/...) are HTTP-only +// per RFC 8555 and have no gRPC equivalents. +service ACMEService { + // CreateEAB creates External Account Binding credentials for the + // authenticated MCIAS user. The returned kid and hmac_key are used + // with any RFC 8555-compliant ACME client to register an account. + rpc CreateEAB(CreateEABRequest) returns (CreateEABResponse); + + // SetConfig sets the ACME configuration for a CA mount. + // Currently configures the default issuer used for ACME certificate issuance. + rpc SetConfig(SetConfigRequest) returns (SetConfigResponse); + + // ListAccounts returns all ACME accounts for a CA mount. Admin only. + rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse); + + // ListOrders returns all ACME orders for a CA mount. Admin only. + rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse); +} + +message CreateEABRequest { + string mount = 1; +} + +message CreateEABResponse { + // kid is the key identifier to pass to the ACME client. + string kid = 1; + // hmac_key is the raw 32-byte HMAC-SHA256 key. + // Base64url-encode this value when configuring an ACME client. + bytes hmac_key = 2; +} + +message SetConfigRequest { + string mount = 1; + // default_issuer is the name of the CA issuer to use for ACME certificates. + // The issuer must already exist on the CA mount. + string default_issuer = 2; +} + +message SetConfigResponse {} + +message ListAccountsRequest { + string mount = 1; +} + +message ListAccountsResponse { + repeated ACMEAccount accounts = 1; +} + +message ACMEAccount { + string id = 1; + string status = 2; + repeated string contact = 3; + string mcias_username = 4; + google.protobuf.Timestamp created_at = 5; +} + +message ListOrdersRequest { + string mount = 1; +} + +message ListOrdersResponse { + repeated ACMEOrder orders = 1; +} + +message ACMEOrder { + string id = 1; + string account_id = 2; + string status = 3; + // identifiers are in "type:value" format, e.g. "dns:example.com". + repeated string identifiers = 4; + google.protobuf.Timestamp created_at = 5; + google.protobuf.Timestamp expires_at = 6; +} diff --git a/proto/metacrypt/v2/auth.proto b/proto/metacrypt/v2/auth.proto new file mode 100644 index 0000000..95ba669 --- /dev/null +++ b/proto/metacrypt/v2/auth.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package metacrypt.v2; + +import "google/protobuf/timestamp.proto"; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2"; + +service AuthService { + rpc Login(LoginRequest) returns (LoginResponse); + rpc Logout(LogoutRequest) returns (LogoutResponse); + rpc TokenInfo(TokenInfoRequest) returns (TokenInfoResponse); +} + +message LoginRequest { + string username = 1; + string password = 2; + string totp_code = 3; +} + +message LoginResponse { + string token = 1; + google.protobuf.Timestamp expires_at = 2; +} + +message LogoutRequest {} +message LogoutResponse {} + +message TokenInfoRequest {} +message TokenInfoResponse { + string username = 1; + repeated string roles = 2; + bool is_admin = 3; +} diff --git a/proto/metacrypt/v2/ca.proto b/proto/metacrypt/v2/ca.proto new file mode 100644 index 0000000..7e9affb --- /dev/null +++ b/proto/metacrypt/v2/ca.proto @@ -0,0 +1,246 @@ +syntax = "proto3"; + +package metacrypt.v2; + +import "google/protobuf/timestamp.proto"; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2"; + +// CAService provides typed, authenticated access to CA engine operations. +// All RPCs require the service to be unsealed. Write operations (CreateIssuer, +// DeleteIssuer, ImportRoot, IssueCert, RenewCert) require authentication. +// Admin-only operations (CreateIssuer, DeleteIssuer, ImportRoot) additionally +// require the caller to have admin privileges. +service CAService { + // ImportRoot imports an existing root CA certificate and private key. + // Admin only. Only allowed when no valid root exists. + rpc ImportRoot(ImportRootRequest) returns (ImportRootResponse); + + // GetRoot returns the root CA certificate for a mount. + rpc GetRoot(GetRootRequest) returns (GetRootResponse); + + // CreateIssuer creates a new intermediate CA issuer signed by the root. + // Admin only. + rpc CreateIssuer(CreateIssuerRequest) returns (CreateIssuerResponse); + + // DeleteIssuer removes an issuer and its key material. Admin only. + rpc DeleteIssuer(DeleteIssuerRequest) returns (DeleteIssuerResponse); + + // ListIssuers returns the names of all configured issuers. Auth required. + rpc ListIssuers(ListIssuersRequest) returns (ListIssuersResponse); + + // GetIssuer returns the PEM-encoded certificate for a named issuer. + rpc GetIssuer(GetIssuerRequest) returns (GetIssuerResponse); + + // GetChain returns the full PEM certificate chain for a named issuer. + rpc GetChain(CAServiceGetChainRequest) returns (CAServiceGetChainResponse); + + // IssueCert issues a new leaf certificate from a named issuer. Auth required. + rpc IssueCert(IssueCertRequest) returns (IssueCertResponse); + + // GetCert retrieves a certificate record by serial number. Auth required. + rpc GetCert(GetCertRequest) returns (GetCertResponse); + + // ListCerts lists all certificate records for a mount. Auth required. + rpc ListCerts(ListCertsRequest) returns (ListCertsResponse); + + // RenewCert renews an existing certificate, generating a new key and serial. + // Auth required. + rpc RenewCert(RenewCertRequest) returns (RenewCertResponse); +} + +// --- ImportRoot --- + +message ImportRootRequest { + string mount = 1; + // cert_pem is the PEM-encoded root CA certificate. + bytes cert_pem = 2; + // key_pem is the PEM-encoded private key for the root CA. + bytes key_pem = 3; +} + +message ImportRootResponse { + string common_name = 1; + google.protobuf.Timestamp expires_at = 2; +} + +// --- GetRoot --- + +message GetRootRequest { + string mount = 1; +} + +message GetRootResponse { + bytes cert_pem = 1; +} + +// --- CreateIssuer --- + +message CreateIssuerRequest { + string mount = 1; + // name is the unique identifier for this issuer within the mount. + string name = 2; + // key_algorithm overrides the mount-level default (e.g. "ecdsa", "rsa"). + string key_algorithm = 3; + // key_size overrides the mount-level default (e.g. 256, 2048). + int32 key_size = 4; + // expiry is the lifetime of the issuer certificate (e.g. "26280h"). + // Defaults to 3 years if empty. + string expiry = 5; + // max_ttl is the maximum TTL for leaf certificates issued by this issuer + // (e.g. "2160h"). Defaults to 90 days if empty. + string max_ttl = 6; +} + +message CreateIssuerResponse { + string name = 1; + // cert_pem is the PEM-encoded issuer certificate. + bytes cert_pem = 2; +} + +// --- DeleteIssuer --- + +message DeleteIssuerRequest { + string mount = 1; + string name = 2; +} + +message DeleteIssuerResponse {} + +// --- ListIssuers --- + +message ListIssuersRequest { + string mount = 1; +} + +message ListIssuersResponse { + repeated string issuers = 1; +} + +// --- GetIssuer --- + +message GetIssuerRequest { + string mount = 1; + string name = 2; +} + +message GetIssuerResponse { + bytes cert_pem = 1; +} + +// --- GetChain --- + +message CAServiceGetChainRequest { + string mount = 1; + string issuer = 2; +} + +message CAServiceGetChainResponse { + // chain_pem contains the issuer certificate followed by the root certificate, + // PEM-encoded and concatenated. + bytes chain_pem = 1; +} + +// --- IssueCert --- + +message IssueCertRequest { + string mount = 1; + // issuer is the name of the issuer to sign the certificate. + string issuer = 2; + // profile selects the certificate profile (e.g. "server", "client"). + // Defaults to "server" if empty. + string profile = 3; + string common_name = 4; + repeated string dns_names = 5; + repeated string ip_addresses = 6; + // ttl overrides the profile's default validity period (e.g. "8760h"). + string ttl = 7; + // key_algorithm overrides the issuer-level default. + string key_algorithm = 8; + // key_size overrides the issuer-level default. + int32 key_size = 9; + repeated string key_usages = 10; + repeated string ext_key_usages = 11; +} + +message IssueCertResponse { + string serial = 1; + string common_name = 2; + repeated string sans = 3; + string issued_by = 4; + google.protobuf.Timestamp expires_at = 5; + // cert_pem is the PEM-encoded leaf certificate. + bytes cert_pem = 6; + // key_pem is the PEM-encoded private key for the leaf certificate. + // This is the only time the private key is returned; it is not stored. + bytes key_pem = 7; + // chain_pem contains the full chain: leaf + issuer + root, PEM-concatenated. + bytes chain_pem = 8; +} + +// --- GetCert --- + +message GetCertRequest { + string mount = 1; + string serial = 2; +} + +message GetCertResponse { + CertRecord cert = 1; +} + +// --- ListCerts --- + +message ListCertsRequest { + string mount = 1; +} + +message ListCertsResponse { + repeated CertSummary certs = 1; +} + +// --- RenewCert --- + +message RenewCertRequest { + string mount = 1; + string serial = 2; +} + +message RenewCertResponse { + string serial = 1; + string common_name = 2; + repeated string sans = 3; + string issued_by = 4; + google.protobuf.Timestamp expires_at = 5; + bytes cert_pem = 6; + bytes key_pem = 7; + bytes chain_pem = 8; +} + +// --- Shared message types --- + +// CertRecord is the full certificate record including the PEM-encoded cert. +message CertRecord { + string serial = 1; + string issuer = 2; + string common_name = 3; + repeated string sans = 4; + string profile = 5; + string issued_by = 6; + google.protobuf.Timestamp issued_at = 7; + google.protobuf.Timestamp expires_at = 8; + // cert_pem is the PEM-encoded certificate. + bytes cert_pem = 9; +} + +// CertSummary is a lightweight certificate record without the PEM data, +// suitable for list responses. +message CertSummary { + string serial = 1; + string issuer = 2; + string common_name = 3; + string profile = 4; + string issued_by = 5; + google.protobuf.Timestamp issued_at = 6; + google.protobuf.Timestamp expires_at = 7; +} diff --git a/proto/metacrypt/v2/common.proto b/proto/metacrypt/v2/common.proto new file mode 100644 index 0000000..a8ee7bb --- /dev/null +++ b/proto/metacrypt/v2/common.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; + +package metacrypt.v2; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2"; + +// Common message types shared across metacrypt.v2 services. +// Currently empty; reserved for future shared types. diff --git a/proto/metacrypt/v2/engine.proto b/proto/metacrypt/v2/engine.proto new file mode 100644 index 0000000..7b22fa1 --- /dev/null +++ b/proto/metacrypt/v2/engine.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +package metacrypt.v2; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2"; + +// EngineService manages the lifecycle of engine mounts. +// In v2, typed service RPCs (e.g. CAService) replace the generic Execute RPC +// for all engine operations. +service EngineService { + rpc Mount(MountRequest) returns (MountResponse); + rpc Unmount(UnmountRequest) returns (UnmountResponse); + rpc ListMounts(ListMountsRequest) returns (ListMountsResponse); +} + +message MountRequest { + string name = 1; + string type = 2; + // config holds engine-specific configuration as key-value string pairs. + map config = 3; +} +message MountResponse {} + +message UnmountRequest { + string name = 1; +} +message UnmountResponse {} + +message ListMountsRequest {} +message ListMountsResponse { + repeated MountInfo mounts = 1; +} + +message MountInfo { + string name = 1; + string type = 2; + string mount_path = 3; +} diff --git a/proto/metacrypt/v2/pki.proto b/proto/metacrypt/v2/pki.proto new file mode 100644 index 0000000..78a9c74 --- /dev/null +++ b/proto/metacrypt/v2/pki.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package metacrypt.v2; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2"; + +// PKIService provides unauthenticated access to public CA certificates. +// These endpoints only require the service to be unsealed. +service PKIService { + rpc GetRootCert(GetRootCertRequest) returns (GetRootCertResponse); + rpc GetChain(GetChainRequest) returns (GetChainResponse); + rpc GetIssuerCert(GetIssuerCertRequest) returns (GetIssuerCertResponse); +} + +message GetRootCertRequest { + string mount = 1; +} +message GetRootCertResponse { + bytes cert_pem = 1; +} + +message GetChainRequest { + string mount = 1; + string issuer = 2; +} +message GetChainResponse { + bytes chain_pem = 1; +} + +message GetIssuerCertRequest { + string mount = 1; + string issuer = 2; +} +message GetIssuerCertResponse { + bytes cert_pem = 1; +} diff --git a/proto/metacrypt/v2/policy.proto b/proto/metacrypt/v2/policy.proto new file mode 100644 index 0000000..93ea255 --- /dev/null +++ b/proto/metacrypt/v2/policy.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; + +package metacrypt.v2; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2"; + +service PolicyService { + rpc CreatePolicy(CreatePolicyRequest) returns (CreatePolicyResponse); + rpc ListPolicies(ListPoliciesRequest) returns (ListPoliciesResponse); + rpc GetPolicy(GetPolicyRequest) returns (GetPolicyResponse); + rpc DeletePolicy(DeletePolicyRequest) returns (DeletePolicyResponse); +} + +message PolicyRule { + string id = 1; + int32 priority = 2; + string effect = 3; + repeated string usernames = 4; + repeated string roles = 5; + repeated string resources = 6; + repeated string actions = 7; +} + +message CreatePolicyRequest { + PolicyRule rule = 1; +} + +message CreatePolicyResponse { + PolicyRule rule = 1; +} + +message ListPoliciesRequest {} +message ListPoliciesResponse { + repeated PolicyRule rules = 1; +} + +message GetPolicyRequest { + string id = 1; +} + +message GetPolicyResponse { + PolicyRule rule = 1; +} + +message DeletePolicyRequest { + string id = 1; +} + +message DeletePolicyResponse {} diff --git a/proto/metacrypt/v2/system.proto b/proto/metacrypt/v2/system.proto new file mode 100644 index 0000000..07bc769 --- /dev/null +++ b/proto/metacrypt/v2/system.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package metacrypt.v2; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2;metacryptv2"; + +service SystemService { + rpc Status(StatusRequest) returns (StatusResponse); + rpc Init(InitRequest) returns (InitResponse); + rpc Unseal(UnsealRequest) returns (UnsealResponse); + rpc Seal(SealRequest) returns (SealResponse); +} + +message StatusRequest {} +message StatusResponse { + string state = 1; +} + +message InitRequest { + string password = 1; +} +message InitResponse { + string state = 1; +} + +message UnsealRequest { + string password = 1; +} +message UnsealResponse { + string state = 1; +} + +message SealRequest {} +message SealResponse { + string state = 1; +} diff --git a/web/templates/issuer_detail.html b/web/templates/issuer_detail.html new file mode 100644 index 0000000..4872118 --- /dev/null +++ b/web/templates/issuer_detail.html @@ -0,0 +1,60 @@ +{{define "title"}} - Issuer: {{.IssuerName}}{{end}} +{{define "content"}} +

Issuer: {{.IssuerName}}

+ +

+ ← PKI: {{.MountName}} +  —  + Download Issuer Cert (PEM) +

+ +

Certificates

+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +{{if .Certs}} + + + + + + + + + + + + + {{range .Certs}} + + + + + + + + + {{end}} + +
Common NameProfileSerialIssued ByIssued AtExpires At
{{index . "cn"}}{{index . "profile"}}{{index . "serial"}}{{index . "issued_by"}}{{index . "issued_at"}}{{index . "expires_at"}}
+{{else}} +

No certificates found{{if .NameFilter}} matching “{{.NameFilter}}”{{end}}.

+{{end}} +{{end}} diff --git a/web/templates/pki.html b/web/templates/pki.html index 1611e7a..4e8f187 100644 --- a/web/templates/pki.html +++ b/web/templates/pki.html @@ -62,7 +62,7 @@ {{range .Issuers}} - {{.}} + {{.}} Download Cert (PEM) {{end}} @@ -72,6 +72,72 @@

No issuers configured.

{{end}} +{{if and .HasRoot .Issuers}} +

Issue Certificate

+{{if .IssuedCert}} +
+

Certificate issued successfully.

+
+ + +
+
+ + +
+
+ + +
+

Serial: {{index .IssuedCert "serial"}} — Expires: {{index .IssuedCert "expires_at"}}

+
+{{else}} +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ SANs +
+
+ + +
+
+ + +
+
+
+ +
+{{end}} +{{end}} + {{if .IsAdmin}} {{if .HasRoot}}

Create Issuer