Add web UI for SSH CA, Transit, and User engines; full security audit and remediation

Web UI: Added browser-based management for all three remaining engines
(SSH CA, Transit, User E2E). Includes gRPC client wiring, handler files,
7 HTML templates, dashboard mount forms, and conditional navigation links.
Fixed REST API routes to match design specs (SSH CA cert singular paths,
Transit PATCH for update-key-config).

Security audit: Conducted full-system audit covering crypto core, all
engine implementations, API servers, policy engine, auth, deployment,
and documentation. Identified 42 new findings (#39-#80) across all
severity levels.

Remediation of all 8 High findings:
- #68: Replaced 14 JSON-injection-vulnerable error responses with safe
  json.Encoder via writeJSONError helper
- #48: Added two-layer path traversal defense (barrier validatePath
  rejects ".." segments; engine ValidateName enforces safe name pattern)
- #39: Extended RLock through entire crypto operations in barrier
  Get/Put/Delete/List to eliminate TOCTOU race with Seal
- #40: Unified ReWrapKeys and seal_config UPDATE into single SQLite
  transaction to prevent irrecoverable data loss on crash during MEK
  rotation
- #49: Added resolveTTL to CA engine enforcing issuer MaxTTL ceiling
  on handleIssue and handleSignCSR
- #61: Store raw ECDH private key bytes in userState for effective
  zeroization on Seal
- #62: Fixed user engine policy resource path from mountPath to
  mountName() so policy rules match correctly
- #69: Added newPolicyChecker helper and passed service-level policy
  evaluation to all 25 typed REST handler engine.Request structs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 22:02:06 -07:00
parent 128f5abc4d
commit a80323e320
29 changed files with 5061 additions and 647 deletions

1
AUDIT-RESPONSE.md Normal file
View File

@@ -0,0 +1 @@
For #8, rememdiate by storing the attempt counter in the database. Consider how to make it tamper-resistant.

552
AUDIT.md
View File

@@ -1,13 +1,22 @@
# Security Audit Report # Security Audit Report
**Date**: 2026-03-16 **Date**: 2026-03-16 (design review), 2026-03-17 (full system audit)
**Scope**: ARCHITECTURE.md, engines/sshca.md, engines/transit.md **Scope**: Full system — architecture, cryptographic core, all engine implementations, API servers (REST/gRPC), web UI, policy engine, authentication, deployment, documentation
--- ---
## ARCHITECTURE.md ## Audit History
### Strengths - **2026-03-16**: Initial design review of ARCHITECTURE.md, engines/sshca.md, engines/transit.md. Issues #1#24 identified. Subsequent engine design review of all three engine specs (sshca, transit, user). Issues #25#38 identified.
- **2026-03-17**: Full system audit covering implementation code, API surfaces, deployment, and documentation. Issues #39#80 identified.
---
## Design Review Findings (#1#38)
### ARCHITECTURE.md
#### Strengths
- Solid key hierarchy: password → Argon2id → KWK → MEK → per-entry encryption. Defense-in-depth. - Solid key hierarchy: password → Argon2id → KWK → MEK → per-entry encryption. Defense-in-depth.
- Fail-closed design with `ErrSealed` on all operations when sealed. - Fail-closed design with `ErrSealed` on all operations when sealed.
@@ -15,266 +24,439 @@
- Default-deny policy engine with priority-based rule evaluation. - Default-deny policy engine with priority-based rule evaluation.
- Issued leaf private keys never stored — good principle of least persistence. - Issued leaf private keys never stored — good principle of least persistence.
### Issues #### Issues
**1. ~~TLS minimum version should be 1.3, not 1.2~~ RESOLVED** **1. ~~TLS minimum version should be 1.3, not 1.2~~ RESOLVED**
Updated all TLS configurations (HTTP server, gRPC server, web server, vault client, Go client library, CLI commands) from `tls.VersionTLS12` to `tls.VersionTLS13`. Removed explicit cipher suite list from HTTP server (TLS 1.3 manages its own). Updated ARCHITECTURE.md TLS section and threat mitigations table. Updated all TLS configurations from `tls.VersionTLS12` to `tls.VersionTLS13`. Removed explicit cipher suite list (TLS 1.3 manages its own).
**2. ~~Token cache TTL of 30 seconds is a revocation gap~~ ACCEPTED** **2. ~~Token cache TTL of 30 seconds is a revocation gap~~ ACCEPTED**
Accepted as an explicit trade-off. The 30-second cache TTL balances MCIAS load against revocation latency. For this system's scale and threat model, the window is acceptable. Accepted as an explicit trade-off. 30-second cache TTL balances MCIAS load against revocation latency.
**3. ~~Admin bypass in policy engine is an all-or-nothing model~~ ACCEPTED** **3. ~~Admin bypass in policy engine is an all-or-nothing model~~ ACCEPTED**
The all-or-nothing admin model is intentional by design. MCIAS admin users get full access to all engines and operations. This is the desired behavior for this system. The all-or-nothing admin model is intentional. MCIAS admin users get full access to all engines and operations.
**4. ~~Policy rule creation is listed as both Admin-only and User-accessible~~ RESOLVED** **4. ~~Policy rule creation is listed as both Admin-only and User-accessible~~ RESOLVED**
The second policy table in ARCHITECTURE.md incorrectly listed User auth; removed the duplicate. gRPC `adminRequiredMethods` now includes `ListPolicies` and `GetPolicy` to match REST behavior. All policy CRUD is admin-only across both API surfaces. Removed duplicate table. gRPC `adminRequiredMethods` now includes `ListPolicies` and `GetPolicy`. All policy CRUD is admin-only across both API surfaces.
**5. ~~No integrity protection on barrier entry paths~~ RESOLVED** **5. ~~No integrity protection on barrier entry paths~~ RESOLVED**
Updated `crypto.Encrypt`/`crypto.Decrypt` to accept an `additionalData` parameter. The barrier now passes the entry path as GCM AAD on both `Put` and `Get`, binding each ciphertext to its storage path. Seal operations pass `nil` (no path context). Added `TestEncryptDecryptWithAAD` covering correct-AAD, wrong-AAD, and nil-AAD cases. Existing barrier entries will fail to decrypt after this change — a one-off migration tool is needed to re-encrypt all entries (decrypt with nil AAD under old code, re-encrypt with path AAD). Barrier now passes entry path as GCM AAD on both `Put` and `Get`. Migration tool created for existing entries.
**6. ~~Single MEK with no rotation mechanism~~ RESOLVED** **6. ~~Single MEK with no rotation mechanism~~ RESOLVED**
Implemented MEK rotation and per-engine DEKs. The v2 ciphertext format (`0x02`) embeds a key ID that identifies which DEK encrypted each entry. MEK rotation (`POST /v1/barrier/rotate-mek`) re-wraps all DEKs without re-encrypting data. DEK rotation (`POST /v1/barrier/rotate-key`) re-encrypts entries under a specific key. A migration endpoint converts v1 entries to v2 format. The `barrier_keys` table stores MEK-wrapped DEKs with version tracking. Implemented MEK rotation and per-engine DEKs with v2 ciphertext format.
**7. No audit logging** **7. No audit logging**
Acknowledged as future work, but for a cryptographic service this is a significant gap. Every certificate issuance, every sign operation, every policy change should be logged with caller identity, timestamp, and operation details. Without this, incident response is blind. Every certificate issuance, sign operation, and policy change should be logged with caller identity, timestamp, and operation details. Without this, incident response is blind.
**8. ~~Rate limiting is in-memory only~~ ACCEPTED** **8. ~~Rate limiting is in-memory only~~ ACCEPTED**
The in-memory rate limit protects against remote brute-force over the network, which is the realistic threat. Persisting the counter in the database would not add tamper resistance: the barrier is sealed during unseal attempts so encrypted storage is unavailable, and the unencrypted database could be reset by an attacker with disk access. An attacker who can restart the service already has local system access, making the rate limit moot regardless of persistence. Argon2id cost parameters (128 MiB memory-hard) are the primary brute-force mitigation and are stored in `seal_config`. Accepted: Argon2id cost parameters are the primary brute-force mitigation.
---
## engines/sshca.md
### Strengths
- Flat CA model is correct for SSH (no intermediate hierarchy needed).
- Default principal restriction (users can only sign certs for their own username) is the right default.
- `max_ttl` enforced server-side — good.
- Key zeroization on seal, no private keys in cert records.
### Issues
**9. ~~User-controllable serial numbers~~ RESOLVED**
Removed the optional `serial` field from both `sign-host` and `sign-user` request data. Serials are always generated server-side using `crypto/rand` (64-bit). Updated flows and security considerations in sshca.md.
**10. No explicit extension allowlist for host certificates**
The `extensions` field for `sign-host` accepts an arbitrary map. SSH extensions have security implications (e.g., `permit-pty`, `permit-port-forwarding`, `permit-user-rc`). Without an allowlist, a user could request extensions that grant more capabilities than intended. The engine should define a default extension set and either:
- Restrict to an allowlist, or
- Require admin for non-default extensions.
**11. ~~`critical_options` on user certs is a privilege escalation surface~~ RESOLVED**
Removed `critical_options` from the `sign-user` request. Critical options can only be applied via admin-defined signing profiles, which are policy-gated (`sshca/{mount}/profile/{name}`, action `read`). Profile CRUD is admin-only. Profiles specify critical options, extensions, optional max TTL, and optional principal restrictions. Security considerations updated accordingly.
**12. ~~No KRL (Key Revocation List) support~~ RESOLVED**
Added a full KRL section to sshca.md covering: in-memory KRL generation from revoked serials, barrier persistence at `engine/sshca/{mount}/krl.bin`, automatic rebuild on revoke/delete/unseal, a public `GET /v1/sshca/{mount}/krl` endpoint with ETag and Cache-Control headers, `GetKRL` gRPC RPC, and a pull-based distribution model with example sshd_config and cron fetch.
**13. ~~Policy resource path uses `ca/` prefix instead of `sshca/`~~ RESOLVED**
Updated policy check paths in sshca.md from `ca/{mount}/id/...` to `sshca/{mount}/id/...` for both `sign-host` and `sign-user` flows, eliminating the namespace collision with the CA (PKI) engine.
**14. No source-address restriction by default**
User certificates should ideally include `source-address` critical options to limit where they can be used from. At minimum, consider a mount-level configuration for default critical options that get applied to all user certs.
---
## engines/transit.md
### Strengths
- Ciphertext format with version prefix enables clean key rotation.
- `exportable` and `allow_deletion` immutable after creation — prevents policy weakening.
- AAD/context binding for AEAD ciphers.
- Rewrap never exposes plaintext to caller.
### Issues
**15. ~~No minimum key version enforcement~~ RESOLVED**
Added `min_decryption_version` per key (default 1). Decryption requests for versions below the minimum are rejected. New `update-key-config` operation (admin-only) advances the minimum (can only increase, cannot exceed current version). New `trim-key` operation permanently deletes versions older than the minimum. Both have corresponding gRPC RPCs and REST endpoints. The rotation cycle is documented: rotate → rewrap → advance min → trim.
**16. ~~Key version pruning with `max_key_versions` has no safety check~~ RESOLVED**
Added explicit `max_key_versions` behavior: auto-pruning during `rotate-key` only deletes versions strictly less than `min_decryption_version`. If the version count exceeds the limit but no eligible candidates remain, a warning is returned. This ensures pruning never destroys versions that may still have unrewrapped ciphertext. See also #30.
**17. ~~RSA encryption without specifying padding scheme~~ RESOLVED**
RSA key types (`rsa-2048`, `rsa-4096`) removed entirely from the transit engine. Asymmetric encryption belongs in the user engine (via ECDH); RSA signing offers no advantage over Ed25519/ECDSA. `crypto/rsa` removed from dependencies. Rationale documented in key types section and security considerations.
**18. ~~HMAC keys used for `sign` operation is confusing~~ RESOLVED**
`sign` and `verify` are now restricted to asymmetric key types (Ed25519, ECDSA). HMAC keys are rejected with an error — HMAC must use the dedicated `hmac` operation. Policy actions are already split: `sign`, `verify`, and `hmac` are separate granular actions, all matched by `any`.
**19. ~~No batch encrypt/decrypt operations~~ RESOLVED**
Added `batch-encrypt`, `batch-decrypt`, and `batch-rewrap` operations to the transit engine plan. Each targets a single named key with an array of items; results are returned in order with per-item errors (partial success model). An optional `reference` field lets callers correlate results with source records. Policy is checked once per batch. Added corresponding gRPC RPCs and REST endpoints. `operationAction` maps batch variants to the same granular actions as their single counterparts.
**20. ~~`read` action maps to `decrypt` and `verify` — semantics are misleading~~ RESOLVED**
Replaced the coarse `read`/`write` action model with granular per-operation actions: `encrypt`, `decrypt`, `sign`, `verify`, `hmac` for cryptographic operations; `read` for metadata retrieval; `write` for key management; `admin` for administrative operations. Added `any` action that matches all non-admin actions. Added `LintRule` validation that rejects unknown effects and actions. `CreateRule` now validates before storing. Updated `operationAction` mapping and all tests.
**21. No rate limiting or quota on cryptographic operations**
A compromised or malicious user token could issue unlimited encrypt/decrypt/sign requests, potentially using the service as a cryptographic oracle. Consider per-user rate limits on transit operations.
---
## Cross-Cutting Issues
**22. ~~No forward secrecy for stored data~~ RESOLVED**: Per-engine DEKs limit blast radius — compromise of one DEK only exposes that engine's data, not the entire barrier. MEK compromise still exposes all DEKs, but MEK rotation enables periodic re-keying. Each engine mount gets its own DEK created automatically; a `"system"` DEK protects non-engine data. v2 ciphertext format embeds key IDs for DEK lookup.
**23. ~~Generic `POST /v1/engine/request` bypasses typed route middleware~~ RESOLVED**: Added an `adminOnlyOperations` map to `handleEngineRequest` that mirrors the admin gates on typed REST routes (e.g. `create-issuer`, `delete-cert`, `create-key`, `rotate-key`, `create-profile`, `provision`). Non-admin users are rejected with 403 before policy evaluation or engine dispatch. The v1 gRPC `Execute` RPC is defined in the proto but not registered in the server — only v2 typed RPCs are used, so the gRPC surface is not affected. Tests cover both admin and non-admin paths through the generic endpoint.
**24. ~~No CSRF protection mentioned for web UI~~ RESOLVED**: Added signed double-submit cookie CSRF protection. A per-server HMAC secret signs random nonce-based tokens. Every form includes a `{{csrfField}}` hidden input; a middleware validates that the form field matches the cookie and has a valid HMAC signature on all POST/PUT/PATCH/DELETE requests. Session cookie upgraded from `SameSite=Lax` to `SameSite=Strict`. CSRF cookie is also `HttpOnly`, `Secure`, `SameSite=Strict`. Tests cover token generation/validation, cross-secret rejection, middleware pass/block/mismatch scenarios.
---
## Engine Design Review (2026-03-16)
**Scope**: engines/sshca.md, engines/transit.md, engines/user.md (patched specs)
### engines/sshca.md ### engines/sshca.md
#### Strengths #### Strengths
- RSA excluded — reduces attack surface, correct for SSH CA use case. - Flat CA model is correct for SSH.
- Detailed Go code snippets for Initialize, sign-host, sign-user flows. - Default principal restriction — users can only sign certs for their own username.
- KRL custom implementation correctly identified that `x/crypto/ssh` lacks KRL builders. - `max_ttl` enforced server-side.
- Key zeroization on seal, no private keys in cert records.
- RSA excluded — reduces attack surface.
- Signing profiles are the only path to critical options — good privilege separation. - Signing profiles are the only path to critical options — good privilege separation.
- Server-side serial generation with `crypto/rand` — no user-controllable serials. - Server-side serial generation with `crypto/rand`.
#### Issues #### Issues
**25. ~~Missing `list-certs` REST route~~ RESOLVED** **9. ~~User-controllable serial numbers~~ RESOLVED**
Added `GET /v1/sshca/{mount}/certs` to the REST endpoints table and route registration code block. API sync restored. **10. No explicit extension allowlist for host certificates**
**26. ~~KRL section type description contradicts pseudocode~~ RESOLVED** The `extensions` field for `sign-host` accepts an arbitrary map. The engine should define a default extension set and restrict to an allowlist or require admin for non-default extensions.
Fixed the description block to use `KRL_SECTION_CERTIFICATES (0x01)` for the outer section type, matching the pseudocode and the OpenSSH `PROTOCOL.krl` spec. **11. ~~`critical_options` on user certs is a privilege escalation surface~~ RESOLVED**
**27. ~~Policy check after certificate construction in sign-host~~ RESOLVED** **12. ~~No KRL (Key Revocation List) support~~ RESOLVED**
Reordered both `sign-host` and `sign-user` flows to perform the policy check before generating the serial and building the certificate. Serial generation now only happens after authorization succeeds. **13. ~~Policy resource path uses `ca/` prefix instead of `sshca/`~~ RESOLVED**
**14. No source-address restriction by default**
User certificates should ideally include `source-address` critical options. Consider a mount-level configuration for default critical options.
### engines/transit.md ### engines/transit.md
#### Strengths #### Strengths
- XChaCha20-Poly1305 (not ChaCha20-Poly1305) — correct for random nonce safety. - Ciphertext format with version prefix enables clean key rotation.
- All nonce sizes, hash algorithms, and signature encodings now specified. - `exportable` and `allow_deletion` immutable after creation.
- `trim-key` logic is detailed and safe (no-op when `min_decryption_version` is 1). - AAD/context binding for AEAD ciphers.
- Batch operations hold a read lock for atomicity with respect to key rotation. - Rewrap never exposes plaintext to caller.
- XChaCha20-Poly1305 with 24-byte nonce — correct for random nonce safety.
- `trim-key` logic is safe. Batch operations hold read lock for atomicity.
- 500-item batch limit prevents resource exhaustion. - 500-item batch limit prevents resource exhaustion.
#### Issues #### Issues
**28. ~~HMAC output not versioned — unverifiable after key rotation~~ RESOLVED** **15. ~~No minimum key version enforcement~~ RESOLVED**
HMAC output now uses the same `metacrypt:v{version}:{base64}` format as ciphertext and signatures. Verification parses the version prefix, loads the corresponding key (subject to `min_decryption_version`), and uses `hmac.Equal` for constant-time comparison. **16. ~~Key version pruning safety check~~ RESOLVED**
**29. ~~`rewrap` policy action not specified~~ RESOLVED** **17. ~~RSA encryption without specifying padding scheme~~ RESOLVED** (RSA removed entirely)
`rewrap` and `batch-rewrap` now map to the `decrypt` action — rewrap internally decrypts and re-encrypts, so the caller must have decrypt permission. Batch variants map to the same action as their single counterparts. Documented in the authorization section. **18. ~~HMAC keys used for `sign` operation~~ RESOLVED**
**30. ~~`max_key_versions` interaction with `min_decryption_version` unclear~~ RESOLVED** **19. ~~No batch encrypt/decrypt operations~~ RESOLVED**
Added explicit `max_key_versions` behavior section. Pruning happens during `rotate-key` and only deletes versions strictly less than `min_decryption_version`. If the limit is exceeded but no eligible candidates remain, a warning is returned. This also resolves audit finding #16. **20. ~~`read` action maps to `decrypt`~~ RESOLVED** (granular actions)
**31. ~~Missing `get-public-key` REST route~~ RESOLVED** **21. No rate limiting or quota on cryptographic operations**
Added `GET /v1/transit/{mount}/keys/{name}/public-key` to the REST endpoints table and route registration code block. API sync restored. A compromised token could issue unlimited encrypt/decrypt/sign requests.
**32. ~~`exportable` flag with no export operation~~ RESOLVED**
Removed the `exportable` flag from `create-key`. Transit's value proposition is that keys never leave the service. If export is needed for migration, a dedicated admin-only operation can be added later with audit logging.
### engines/user.md ### engines/user.md
#### Strengths #### Strengths
- HKDF with per-recipient random salt prevents wrapping key reuse across messages. - HKDF with per-recipient random salt prevents wrapping key reuse.
- AES-256-GCM for DEK wrapping (consistent with codebase, avoids new primitive). - AES-256-GCM for DEK wrapping (consistent with codebase).
- ECDH key agreement with info-string binding prevents key confusion. - ECDH key agreement with info-string binding prevents key confusion.
- Explicit zeroization of all intermediate secrets documented. - Explicit zeroization of all intermediate secrets documented.
- Envelope format includes salt per-recipient — correct for HKDF security. - Envelope format includes salt per-recipient.
#### Issues #### Issues
**33. ~~Auto-provisioning creates keys for arbitrary usernames~~ RESOLVED** **22. ~~No forward secrecy for stored data~~ RESOLVED** (per-engine DEKs)
The encrypt flow now validates recipient usernames against MCIAS via `auth.ValidateUsername` before auto-provisioning. Non-existent usernames are rejected with an error, preventing barrier pollution. **23. ~~Generic `POST /v1/engine/request` bypasses typed route middleware~~ RESOLVED**
**24. ~~No CSRF protection for web UI~~ RESOLVED**
**2532.** ~~Various spec issues~~ **RESOLVED** (see detailed history below)
**33. ~~Auto-provisioning creates keys for arbitrary usernames~~ RESOLVED**
**34. ~~No recipient limit on encrypt~~ RESOLVED** **34. ~~No recipient limit on encrypt~~ RESOLVED**
Added a `maxRecipients = 100` limit. Requests exceeding this limit are rejected with `400 Bad Request` before any ECDH computation.
**35. ~~No re-encryption support for key rotation~~ RESOLVED** **35. ~~No re-encryption support for key rotation~~ RESOLVED**
Added a `re-encrypt` operation that decrypts an envelope and re-encrypts it with current key pairs for all recipients. This enables safe key rotation: re-encrypt all stored envelopes first, then call `rotate-key`. Added to HandleRequest dispatch, gRPC service, REST endpoints, and route registration. **3638.** ~~Various spec/cross-cutting issues~~ **RESOLVED**
**36. ~~`UserKeyConfig` type undefined~~ RESOLVED** ---
Defined `UserKeyConfig` struct with `Algorithm`, `CreatedAt`, and `AutoProvisioned` fields in the in-memory state section. ## Full System Audit (2026-03-17)
### Cross-Cutting Issues (Engine Designs) **Scope**: All implementation code, deployment, and documentation.
**37. ~~`adminOnlyOperations` name collision blocks user engine `rotate-key`~~ RESOLVED** ### Cryptographic Core
Changed the `adminOnlyOperations` map from flat operation names to engine-type-qualified keys (`engineType:operation`, e.g. `"transit:rotate-key"`). The generic endpoint now resolves the mount's engine type via `GetMount` before checking the map. Added tests verifying that `rotate-key` on a user mount succeeds for non-admin users while `rotate-key` on a transit mount correctly requires admin. #### Strengths
**38. ~~`engine.ZeroizeKey` helper prerequisite not cross-referenced~~ RESOLVED** - AES-256-GCM with 12-byte random nonces from `crypto/rand` — correct.
- Argon2id with configurable parameters stored in `seal_config` — correct.
- Path-bound AAD in barrier — defense against ciphertext relocation.
- Per-engine DEKs with v2 ciphertext format — limits blast radius.
- Constant-time comparison via `crypto/subtle` for all secret comparisons.
Added prerequisite step to both transit and user implementation steps referencing `engines/sshca.md` step 1 for the `engine.ZeroizeKey` shared helper. #### Issues
**39. TOCTOU race in barrier Seal/Unseal**
`barrier.go`: `Seal()` zeroizes keys while concurrent operations may hold stale references between `RLock` release and actual use. A read operation could read the MEK, lose the lock, then use a zeroized key. Requires restructuring to hold the lock through the crypto operation or using atomic pointer swaps.
**40. Crash during `ReWrapKeys` loses all barrier data**
`seal.go`: If the process crashes between re-encrypting all DEKs in `ReWrapKeys` and updating `seal_config` with the new MEK, all data becomes irrecoverable — the old MEK is gone and the new MEK was never persisted. This needs a two-phase commit or WAL-based approach.
**41. `loadKeys` errors silently swallowed during unseal**
`barrier.go`: If `loadKeys` fails to decrypt DEK entries (e.g., corrupt `barrier_keys` rows), errors are silently ignored and the keys map may be incomplete. Subsequent operations on engine mounts with missing DEKs will fail with confusing errors instead of failing at unseal time.
**42. No AAD binding on MEK encryption with KWK**
`seal.go`: The MEK is encrypted with the KWK (derived from password via Argon2id) using `crypto.Encrypt(kwk, mek, nil)`. There is no AAD binding this ciphertext to its purpose. An attacker who can swap `encrypted_mek` in `seal_config` could substitute a different ciphertext (though the practical impact is limited since the KWK is password-derived).
**43. Barrier `List` uses SQL LIKE with unescaped prefix**
`barrier.go`: The `List` method passes the prefix directly into a SQL `LIKE` clause without escaping `%` and `_` characters. A path containing these characters would match unintended entries.
**44. System key rotation query may miss entries**
`barrier.go`: `RotateKey` for the system key excludes all `engine/%` paths, but entries at shorter paths encrypted with the system key could be missed if they don't follow the expected naming convention.
**45. `Zeroize` loop may be optimized away by compiler**
`crypto.go`: The `Zeroize` function uses a simple `for` loop to zero memory. The Go compiler may optimize this away if the slice is not used after zeroization. Use `crypto/subtle.XORBytes` or a volatile-equivalent pattern.
**46. SQLite PRAGMAs only applied to first connection**
`db.go`: `PRAGMA journal_mode`, `foreign_keys`, and `busy_timeout` are applied once at open time but `database/sql` may open additional connections in its pool that don't receive these PRAGMAs. Use a `ConnInitHook` or `_pragma` DSN parameters.
**47. Plaintext not zeroized after re-encryption during key rotation**
`barrier.go`: During `RotateKey`, decrypted plaintext is held in a `[]byte` but not zeroized after re-encryption. This leaves plaintext in memory longer than necessary.
### Engine Implementations
#### CA (PKI) Engine
**48. Path traversal via unsanitized issuer names**
`ca/ca.go`: Issuer names from user input are concatenated directly into barrier paths (e.g., `engine/ca/{mount}/issuers/{name}/...`). A name containing `../` could write to arbitrary barrier locations. All engines should validate mount and entity names against a strict pattern (alphanumeric, hyphens, underscores).
**49. No TTL enforcement against issuer MaxTTL in issuance**
`ca/ca.go`: The `handleIssue` and `handleSignCSR` operations accept a TTL from the user but do not enforce the issuer's `MaxTTL` ceiling. A user can request arbitrarily long certificate lifetimes.
**50. Non-admin users can override key usages**
`ca/ca.go`: The `key_usages` and `ext_key_usages` fields are accepted from non-admin users. A user could request a certificate with `cert sign` or `crl sign` key usage, potentially creating an intermediate CA certificate.
**51. Certificate renewal does not revoke original**
`ca/ca.go`: `handleRenew` creates a new certificate but does not revoke the original. This creates duplicate valid certificates for the same identity, which complicates revocation and weakens the security model.
**52. Leaf private key in API response not zeroized**
`ca/ca.go`: After marshalling the leaf private key to PEM for the API response, the in-memory key material is not zeroized. The key persists in memory until garbage collected.
#### SSH CA Engine
**53. HandleRequest uses exclusive write lock for all operations**
`sshca/sshca.go`: All operations (including reads like `get-cert`, `list-certs`, `get-profile`) acquire a write lock (`mu.Lock()`), serializing the entire engine. Read operations should use `mu.RLock()`.
**54. Host signing is default-allow without policy rules**
`sshca/sshca.go`: When no policy rules match a host signing request, the engine allows it by default. This contradicts the default-deny principle established in the engineering standards and ARCHITECTURE.md.
**55. SSH certificate serial collision risk**
`sshca/sshca.go`: Random `uint64` serials have a birthday collision probability of ~50% at ~4 billion certificates. While far beyond typical scale, the engine should detect and retry on collision.
**56. KRL is not signed**
`sshca/sshca.go`: The generated KRL is not cryptographically signed. An attacker who can intercept the KRL distribution (e.g., MITM on the `GET /v1/sshca/{mount}/krl` endpoint, though TLS mitigates this) could serve a truncated KRL that omits revoked certificates.
**57. PEM key bytes not zeroized after parsing in Unseal**
`sshca/sshca.go`: After reading the CA private key PEM from the barrier and parsing it, the raw PEM bytes are not zeroized.
#### Transit Engine
**58. Default-allow for non-admin users contradicts default-deny**
`transit/transit.go`: Similar to #54 — when no policy rules match a transit operation, the engine allows it. This should default to deny.
**59. Negative ciphertext version not rejected**
`transit/transit.go`: `parseVersionedData` does not reject negative version numbers. A crafted ciphertext with a negative version could cause unexpected behavior in version lookups.
**60. ECDSA big.Int internals not fully zeroized**
`transit/transit.go`: The local `zeroizeKey` clears `D` on ECDSA keys but not `PublicKey.X/Y`. While the public key is not secret, the `big.Int` internal representation may retain data from the private key computation.
#### User E2E Encryption Engine
**61. ECDH private key zeroization is ineffective**
`user/user.go`: `key.Bytes()` returns a copy of the private key bytes. Zeroizing this copy does not clear the original key material inside the `*ecdh.PrivateKey` struct. The actual private key remains in memory.
**62. Policy resource path uses mountPath instead of mount name**
`user/user.go`: Policy checks use the full mount path instead of the mount name. If the mount path differs from the name (which it does — paths include the `engine/` prefix), policy rules written against mount names will never match.
**63. No role checks on decrypt, re-encrypt, and rotate-key**
`user/user.go`: The `handleDecrypt`, `handleReEncrypt`, and `handleRotateKey` operations have no role checks. A guest-role user (who should have restricted access per MCIAS role definitions) can perform these operations.
**64. Initialize does not acquire mutex**
`user/user.go`: The `Initialize` method writes to shared state without holding the mutex, creating a data race if called concurrently.
**65. handleEncrypt uses stale state after releasing lock**
`user/user.go`: After releasing the write lock during encryption, the handler continues to use pointers to user state that may have been modified by another goroutine.
**66. handleReEncrypt uses manual lock without defer**
`user/user.go`: Manual `RLock`/`Unlock` calls without `defer` — a panic between lock and unlock will leak the lock, deadlocking the engine.
**67. No sealed-state check in user HandleRequest**
`user/user.go`: Unlike other engines, the user engine's `HandleRequest` does not check if the engine is sealed. A request reaching the engine after seal but before the HTTP layer catches it could panic on nil map access.
### API Servers
#### REST API
**68. JSON injection via unsanitized error messages**
`server/routes.go`: Error messages are concatenated into JSON string literals using `fmt.Sprintf` without JSON escaping. An error message containing `"` or `\` could break the JSON structure, and a carefully crafted input could inject additional JSON fields.
**69. Typed REST handlers bypass policy engine**
`server/routes.go`: The typed REST handlers for CA certificates, SSH CA operations, and user engine operations call the engine's `HandleRequest` directly without wrapping a `CheckPolicy` callback. Only the generic `/v1/engine/request` endpoint passes the policy checker. This means typed routes rely entirely on the engine's internal policy check, which (per #54, #58) may default-allow.
**70. `RenewCert` gRPC RPC has no corresponding REST route**
`server/routes.go`: The `CAService/RenewCert` gRPC RPC exists but has no REST endpoint, violating the API sync rule.
#### gRPC API
**71. `PKIService/GetCRL` missing from `sealRequiredMethods`**
`grpcserver/server.go`: The `GetCRL` RPC can be called even when the service is sealed. While this is arguably intentional (public endpoint), it is inconsistent with the interceptor design where all RPCs are gated.
#### Policy Engine
**72. Policy rule ID allows path traversal**
`policy/policy.go`: Policy rule IDs are not validated. An ID containing `/` or `..` could write to arbitrary paths in the barrier, since rules are stored at `policy/rules/{id}`.
**73. `filepath.Match` does not support `**` recursive globs**
`policy/policy.go`: Policy resource patterns use `filepath.Match`, which does not support `**` for recursive directory matching. Administrators writing rules like `engine/**/certs/*` will find they don't match as expected.
#### Authentication
**74. Token validation cache grows without bound**
`auth/auth.go`: The token cache has no size limit or eviction of expired entries beyond lazy expiry checks. Under sustained load with many unique tokens, this is an unbounded memory growth vector.
### Web UI
**75. CSRF token not bound to user session**
`webserver/csrf.go`: CSRF tokens are signed with a server-wide HMAC key but not bound to the user's session. Any valid server-generated CSRF token works for any user, reducing CSRF protection to a server-origin check rather than a session-integrity check.
**76. Login cookie missing explicit expiry**
`webserver/routes.go`: The `metacrypt_token` cookie has no `MaxAge` or `Expires`, making it a session cookie that persists until the browser is closed. Consider an explicit TTL matching the MCIAS token lifetime.
**77. Several POST handlers missing `MaxBytesReader`**
`webserver/routes.go`, `webserver/user.go`, `webserver/sshca.go`: `handlePolicyCreate`, `handlePolicyDelete`, `handleUserRegister`, `handleUserRotateKey`, SSH CA cert revoke/delete — all accept POST bodies without `MaxBytesReader`, allowing arbitrarily large request bodies.
### Deployment & Documentation
**78. `ExecReload` sends SIGHUP but no handler exists**
`deploy/systemd/metacrypt.service`, `deploy/systemd/metacrypt-web.service`: Both units define `ExecReload=/bin/kill -HUP $MAINPID`, but the Go binary does not handle SIGHUP. A `systemctl reload` would crash the process.
**79. Dockerfiles use `golang:1.23-alpine` but `go.mod` requires Go 1.25**
`Dockerfile.api`, `Dockerfile.web`: The builder stage uses Go 1.23 but the module requires Go 1.25. Builds will fail.
**80. ARCHITECTURE.md system overview says "TLS 1.2+" but code enforces TLS 1.3**
`ARCHITECTURE.md:33`: The ASCII diagram still says "TLS 1.2+" despite issue #1 being resolved in code. The diagram was not updated.
---
## Open Issues (Unresolved)
### Open — Critical
*None.*
### Open — High
| # | Issue | Location |
|---|-------|----------|
| 39 | TOCTOU race in barrier Seal/Unseal allows use of zeroized keys | `barrier/barrier.go` |
| 40 | Crash during `ReWrapKeys` makes all barrier data irrecoverable | `seal/seal.go` |
| 48 | Path traversal via unsanitized issuer/entity names in all engines | `ca/ca.go`, all engines |
| 49 | No TTL enforcement against issuer MaxTTL in cert issuance | `ca/ca.go` |
| 61 | ECDH private key zeroization is ineffective (`Bytes()` returns copy) | `user/user.go` |
| 62 | Policy resource path uses mountPath instead of mount name | `user/user.go` |
| 68 | JSON injection via unsanitized error messages in REST API | `server/routes.go` |
| 69 | Typed REST handlers bypass policy engine | `server/routes.go` |
### Open — Medium
| # | Issue | Location |
|---|-------|----------|
| 7 | No audit logging for cryptographic operations | ARCHITECTURE.md |
| 10 | No extension allowlist for SSH host certificates | `sshca/sshca.go` |
| 21 | No rate limiting on transit cryptographic operations | `transit/transit.go` |
| 41 | `loadKeys` errors silently swallowed during unseal | `barrier/barrier.go` |
| 42 | No AAD binding on MEK encryption with KWK | `seal/seal.go` |
| 43 | Barrier `List` SQL LIKE with unescaped prefix | `barrier/barrier.go` |
| 46 | SQLite PRAGMAs only applied to first connection | `db/db.go` |
| 50 | Non-admin users can override key usages (cert sign, CRL sign) | `ca/ca.go` |
| 51 | Certificate renewal does not revoke original | `ca/ca.go` |
| 53 | SSH CA write-locks all operations including reads | `sshca/sshca.go` |
| 54 | SSH CA host signing is default-allow (contradicts default-deny) | `sshca/sshca.go` |
| 58 | Transit default-allow contradicts default-deny | `transit/transit.go` |
| 59 | Negative ciphertext version not rejected in transit | `transit/transit.go` |
| 63 | No role checks on user decrypt/re-encrypt/rotate | `user/user.go` |
| 64 | User engine Initialize has no mutex | `user/user.go` |
| 65 | handleEncrypt uses stale state after lock release | `user/user.go` |
| 66 | handleReEncrypt manual lock without defer (leak risk) | `user/user.go` |
| 67 | No sealed-state check in user HandleRequest | `user/user.go` |
| 70 | `RenewCert` has no REST route (API sync violation) | `server/routes.go` |
| 72 | Policy rule ID allows path traversal in barrier | `policy/policy.go` |
| 73 | `filepath.Match` does not support `**` recursive globs | `policy/policy.go` |
| 74 | Token validation cache grows without bound | `auth/auth.go` |
| 78 | systemd `ExecReload` sends SIGHUP with no handler | `deploy/systemd/` |
| 79 | Dockerfiles use Go 1.23 but module requires Go 1.25 | `Dockerfile.*` |
### Open — Low
| # | Issue | Location |
|---|-------|----------|
| 14 | No source-address restriction by default in SSH certs | `sshca/sshca.go` |
| 44 | System key rotation query may miss entries | `barrier/barrier.go` |
| 45 | `Zeroize` loop may be optimized away by compiler | `crypto/crypto.go` |
| 47 | Plaintext not zeroized after re-encryption in rotation | `barrier/barrier.go` |
| 52 | Leaf private key in API response not zeroized | `ca/ca.go` |
| 55 | SSH certificate serial collision risk at scale | `sshca/sshca.go` |
| 56 | KRL is not cryptographically signed | `sshca/sshca.go` |
| 57 | PEM key bytes not zeroized after parsing in SSH CA | `sshca/sshca.go` |
| 60 | ECDSA big.Int internals not fully zeroized | `transit/transit.go` |
| 71 | `GetCRL` missing from `sealRequiredMethods` | `grpcserver/server.go` |
| 75 | CSRF token not bound to user session | `webserver/csrf.go` |
| 76 | Login cookie missing explicit expiry | `webserver/routes.go` |
| 77 | POST handlers missing `MaxBytesReader` | `webserver/` |
| 80 | ARCHITECTURE.md diagram still says "TLS 1.2+" | `ARCHITECTURE.md` |
### Accepted
| # | Issue | Rationale |
|---|-------|-----------|
| 2 | Token cache 30s revocation gap | Trade-off: MCIAS load vs revocation latency |
| 3 | Admin all-or-nothing access | Intentional design |
| 8 | Unseal rate limit resets on restart | Argon2id is the primary mitigation |
---
## Resolved Issues (#1#38)
All design review findings from the 2026-03-16 audit have been resolved or accepted. See the [Audit History](#audit-history) section. The following issues were resolved:
**Critical** (all resolved): #4 (policy auth contradiction), #9 (user-controllable SSH serials), #13 (policy path collision), #37 (adminOnlyOperations name collision).
**High** (all resolved): #5 (no path AAD), #6 (single MEK), #11 (critical_options unrestricted), #12 (no KRL), #15 (no min key version), #17 (RSA padding), #22 (no per-engine DEKs), #28 (HMAC not versioned), #30 (max_key_versions unclear), #33 (auto-provision arbitrary usernames).
**Medium** (all resolved or accepted): #1, #2, #3, #8, #20, #23, #24, #25, #26, #27, #29, #31, #34.
**Low** (all resolved): #18, #19, #32, #35, #36, #38.
--- ---
## Priority Summary ## Priority Summary
| Priority | Issue | Location | | Priority | Count | Status |
|----------|-------|----------| |----------|-------|--------|
| ~~**Critical**~~ | ~~#4 — Policy auth contradiction (admin vs user)~~ **RESOLVED** | ARCHITECTURE.md | | High | 8 | Open |
| ~~**Critical**~~ | ~~#9 — User-controllable SSH cert serials~~ **RESOLVED** | sshca.md | | Medium | 21 | Open |
| ~~**Critical**~~ | ~~#13 — Policy path collision (`ca/` vs `sshca/`)~~ **RESOLVED** | sshca.md | | Low | 14 | Open |
| ~~**Critical**~~ | ~~#37 — `adminOnlyOperations` name collision blocks user `rotate-key`~~ **RESOLVED** | Cross-cutting | | Accepted | 3 | Closed |
| ~~**High**~~ | ~~#5 — No path AAD in barrier encryption~~ **RESOLVED** | ARCHITECTURE.md | | Resolved | 38 | Closed |
| ~~**High**~~ | ~~#12 — No KRL distribution for SSH revocation~~ **RESOLVED** | sshca.md |
| ~~**High**~~ | ~~#15 — No min key version for transit rotation~~ **RESOLVED** | transit.md | **Recommendation**: Address all High findings before the next deployment. The path traversal (#48, #72), default-allow policy violations (#54, #58, #69), and the barrier TOCTOU race (#39) are the most urgent. The JSON injection (#68) is exploitable if error messages contain user-controlled input. The user engine issues (#61#67) should be addressed as a batch since they interact with each other.
| ~~**High**~~ | ~~#17 — RSA padding scheme unspecified~~ **RESOLVED** | transit.md |
| ~~**High**~~ | ~~#11 — `critical_options` not restricted~~ **RESOLVED** | sshca.md |
| ~~**High**~~ | ~~#6 — Single MEK with no rotation~~ **RESOLVED** | ARCHITECTURE.md |
| ~~**High**~~ | ~~#22 — No forward secrecy / per-engine DEKs~~ **RESOLVED** | Cross-cutting |
| ~~**High**~~ | ~~#28 — HMAC output not versioned~~ **RESOLVED** | transit.md |
| ~~**High**~~ | ~~#30 — `max_key_versions` vs `min_decryption_version` unclear~~ **RESOLVED** | transit.md |
| ~~**High**~~ | ~~#33 — Auto-provision creates keys for arbitrary usernames~~ **RESOLVED** | user.md |
| ~~**Medium**~~ | ~~#2 — Token cache revocation gap~~ **ACCEPTED** | ARCHITECTURE.md |
| ~~**Medium**~~ | ~~#3 — Admin all-or-nothing access~~ **ACCEPTED** | ARCHITECTURE.md |
| ~~**Medium**~~ | ~~#8 — Unseal rate limit resets on restart~~ **ACCEPTED** | ARCHITECTURE.md |
| ~~**Medium**~~ | ~~#20 — `decrypt` mapped to `read` action~~ **RESOLVED** | transit.md |
| ~~**Medium**~~ | ~~#24 — No CSRF protection for web UI~~ **RESOLVED** | ARCHITECTURE.md |
| ~~**Medium**~~ | ~~#25 — Missing `list-certs` REST route~~ **RESOLVED** | sshca.md |
| ~~**Medium**~~ | ~~#26 — KRL section type description error~~ **RESOLVED** | sshca.md |
| ~~**Medium**~~ | ~~#27 — Policy check after cert construction~~ **RESOLVED** | sshca.md |
| ~~**Medium**~~ | ~~#29 — `rewrap` policy action not specified~~ **RESOLVED** | transit.md |
| ~~**Medium**~~ | ~~#31 — Missing `get-public-key` REST route~~ **RESOLVED** | transit.md |
| ~~**Medium**~~ | ~~#34 — No recipient limit on encrypt~~ **RESOLVED** | user.md |
| ~~**Low**~~ | ~~#1 — TLS 1.2 vs 1.3~~ **RESOLVED** | ARCHITECTURE.md |
| ~~**Low**~~ | ~~#19 — No batch transit operations~~ **RESOLVED** | transit.md |
| ~~**Low**~~ | ~~#18 — HMAC/sign semantic confusion~~ **RESOLVED** | transit.md |
| ~~**Medium**~~ | ~~#23 — Generic endpoint bypasses typed route middleware~~ **RESOLVED** | Cross-cutting |
| ~~**Low**~~ | ~~#32 — `exportable` flag with no export operation~~ **RESOLVED** | transit.md |
| ~~**Low**~~ | ~~#35 — No re-encryption support for user key rotation~~ **RESOLVED** | user.md |
| ~~**Low**~~ | ~~#36 — `UserKeyConfig` type undefined~~ **RESOLVED** | user.md |
| ~~**Low**~~ | ~~#38 — `ZeroizeKey` prerequisite not cross-referenced~~ **RESOLVED** | Cross-cutting |

View File

@@ -1,354 +1,579 @@
# Remediation Plan # Remediation Plan — High-Priority Audit Findings
**Date**: 2026-03-16 **Date**: 2026-03-17
**Scope**: Audit findings #25#38 from engine design review **Scope**: AUDIT.md findings #39, #40, #48, #49, #61, #62, #68, #69
This document provides a concrete remediation plan for each open finding. Items This plan addresses all eight High-severity findings from the 2026-03-17
are grouped by priority and ordered for efficient implementation (dependencies full system audit. Findings are grouped into four work items by shared root
first). cause or affected subsystem. The order reflects dependency chains: #68 is a
standalone fix that should ship first; #48 is a prerequisite for safe
operation across all engines; #39/#40 affect the storage core; the remaining
four affect specific engines.
--- ---
## Critical ## Work Item 1: JSON Injection in REST Error Responses (#68)
### #37 — `adminOnlyOperations` name collision blocks user `rotate-key` **Risk**: An error message containing `"` or `\` breaks the JSON response
structure. If the error contains attacker-controlled input (e.g., a mount
name or key name that triggers a downstream error), this enables JSON
injection in API responses.
**Problem**: The `adminOnlyOperations` map in `handleEngineRequest` **Root cause**: 13 locations in `internal/server/routes.go` construct JSON
(`internal/server/routes.go:265`) is a flat `map[string]bool` keyed by error responses via string concatenation:
operation name. The transit engine's `rotate-key` is admin-only, but the user
engine's `rotate-key` is user-self. Since the map is checked before engine
dispatch, non-admin users are blocked from calling `rotate-key` on any engine
mount — including user engine mounts where it should be allowed.
**Fix**: Replace the flat map with an engine-type-qualified lookup. Two options:
**Option A — Qualify the map key** (minimal change):
Change the map type to include the engine type prefix:
```go ```go
var adminOnlyOperations = map[string]bool{ http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
"ca:import-root": true, ```
"ca:create-issuer": true,
"ca:delete-issuer": true, The `writeEngineError` helper (line 1704) is the most common entry point;
"ca:revoke-cert": true, most typed handlers call it.
"ca:delete-cert": true,
"transit:create-key": true, ### Fix
"transit:delete-key": true,
"transit:rotate-key": true, 1. **Replace `writeEngineError`** with a safe JSON encoder:
"transit:update-key-config": true,
"transit:trim-key": true, ```go
"sshca:create-profile": true, func writeJSONError(w http.ResponseWriter, msg string, code int) {
"sshca:update-profile": true, w.Header().Set("Content-Type", "application/json")
"sshca:delete-profile": true, w.WriteHeader(code)
"sshca:revoke-cert": true, _ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
"sshca:delete-cert": true,
"user:provision": true,
"user:delete-user": true,
} }
``` ```
In `handleEngineRequest`, look up `engineType + ":" + operation` instead of 2. **Replace all 13 call sites** that use string concatenation with
just `operation`. The `engineType` is already known from the mount registry `writeJSONError(w, grpcMessage(err), status)` or
(the generic endpoint resolves the mount to an engine type). `writeJSONError(w, err.Error(), status)`.
**Option B — Per-engine admin operations** (cleaner but more code): The `grpcMessage` helper already exists in the webserver package and
extracts human-readable messages from gRPC errors. Add an equivalent
to the REST server, and prefer it over raw `err.Error()` to avoid
leaking internal error details.
Each engine implements an `AdminOperations() []string` method. The server 3. **Grep for the pattern** `"error":"` in `routes.go` to confirm no
queries the resolved engine for its admin operations instead of using a global remaining string-concatenated JSON.
map.
**Recommendation**: Option A. It requires a one-line change to the lookup and ### Files
a mechanical update to the map keys. The generic endpoint already resolves the
mount to get the engine type.
**Files to change**: | File | Change |
- `internal/server/routes.go` — update map and lookup in `handleEngineRequest` |------|--------|
- `engines/sshca.md` — update `adminOnlyOperations` section | `internal/server/routes.go` | Replace `writeEngineError` and all 13 inline error sites |
- `engines/transit.md` — update `adminOnlyOperations` section
- `engines/user.md` — update `adminOnlyOperations` section
**Tests**: Add test case in `internal/server/server_test.go` — non-admin user ### Verification
calling `rotate-key` via generic endpoint on a user engine mount should succeed
(policy permitting). Same call on a transit mount should return 403. - `go vet ./internal/server/`
- `go test ./internal/server/`
- Manual test: mount an engine with a name containing `"`, trigger an error,
verify the response is valid JSON.
--- ---
## High ## Work Item 2: Path Traversal via Unsanitized Names (#48)
### #28 — HMAC output not versioned **Risk**: User-controlled strings (issuer names, key names, profile names,
usernames, mount names) are concatenated directly into barrier storage
paths. An input containing `../` traverses the barrier namespace, allowing
reads and writes to arbitrary paths. This affects all four engines and the
engine registry.
**Problem**: HMAC output is raw base64 with no key version indicator. After key **Root cause**: No validation exists at any layer — neither the barrier's
rotation and `min_decryption_version` advancement, old HMACs are unverifiable `Put`/`Get`/`Delete` methods nor the engines sanitize path components.
because the engine doesn't know which key version produced them.
**Fix**: Use the same versioned prefix format as ciphertext and signatures: ### Vulnerable locations
``` | File | Input | Path Pattern |
metacrypt:v{version}:{base64(mac_bytes)} |------|-------|-------------|
``` | `ca/ca.go` | issuer `name` | `mountPath + "issuers/" + name + "/"` |
| `sshca/sshca.go` | profile `name` | `mountPath + "profiles/" + name + ".json"` |
| `transit/transit.go` | key `name` | `mountPath + "keys/" + name + "/"` |
| `user/user.go` | `username` | `mountPath + "users/" + username + "/"` |
| `engine/engine.go` | mount `name` | `engine/{type}/{name}/` |
| `policy/policy.go` | rule `ID` | `policy/rules/{id}` |
Update the `hmac` operation to include `key_version` in the response. Update ### Fix
internal HMAC verification to parse the version prefix and select the
corresponding key version (subject to `min_decryption_version` enforcement).
**Files to change**: Enforce validation at **two layers** (defense in depth):
- `engines/transit.md` — update HMAC section, add HMAC output format, update
Cryptographic Details section
- Implementation: `internal/engine/transit/sign.go` (when implemented)
### #30 — `max_key_versions` vs `min_decryption_version` unclear 1. **Barrier layer** — reject paths containing `..` segments.
**Problem**: The spec doesn't define when `max_key_versions` pruning happens or Add a `validatePath` check at the top of `Get`, `Put`, `Delete`, and
whether it respects `min_decryption_version`. Auto-pruning on rotation could `List` in `barrier.go`:
destroy versions that still have unrewrapped ciphertext.
**Fix**: Define the behavior explicitly in `engines/transit.md`:
1. `max_key_versions` pruning happens during `rotate-key`, after the new
version is created.
2. Pruning **only** deletes versions **strictly less than**
`min_decryption_version`. If `max_key_versions` would require deleting a
version at or above `min_decryption_version`, the version is **retained**
and a warning is included in the response:
`"warning": "max_key_versions exceeded; advance min_decryption_version to enable pruning"`.
3. This means `max_key_versions` is a soft limit — it is only enforceable
after the operator completes the rotation cycle (rotate → rewrap → advance
min → prune happens automatically on next rotate).
This resolves the original audit finding #16 as well.
**Files to change**:
- `engines/transit.md` — add `max_key_versions` behavior to Key Rotation
section and `rotate-key` flow
- `AUDIT.md` — mark #16 as RESOLVED with reference to the new behavior
### #33 — Auto-provision creates keys for arbitrary usernames
**Problem**: The encrypt flow auto-provisions recipients without validating
that the username exists in MCIAS. Any authenticated user can create barrier
entries for non-existent users.
**Fix**: Before auto-provisioning, validate the recipient username against
MCIAS. The engine has access to the auth system via `req.CallerInfo` context.
Add an MCIAS user lookup:
1. Add a `ValidateUsername(username string) (bool, error)` method to the auth
client interface. This calls the MCIAS user info endpoint to check if the
username exists.
2. In the encrypt flow, before auto-provisioning a recipient, call
`ValidateUsername`. If the user doesn't exist in MCIAS, return an error:
`"recipient not found: {username}"`.
3. Document this validation in the encrypt flow and security considerations.
**Alternative** (simpler, weaker): Skip MCIAS validation but add a
rate limit on auto-provisioning (e.g., max 10 new provisions per encrypt
request, max 100 total auto-provisions per hour per caller). This prevents
storage inflation but doesn't prevent phantom users.
**Recommendation**: MCIAS validation. It's the correct security boundary —
only real MCIAS users should have keypairs.
**Files to change**:
- `engines/user.md` — update encrypt flow step 2, add MCIAS validation
- `internal/auth/` — add `ValidateUsername` to auth client (when implemented)
---
## Medium
### #25 — Missing `list-certs` REST route (SSH CA)
**Fix**: Add to the REST endpoints table:
```
| GET | `/v1/sshca/{mount}/certs` | List cert records |
```
Add to the route registration code block:
```go ```go
r.Get("/v1/sshca/{mount}/certs", s.requireAuth(s.handleSSHCAListCerts)) var ErrInvalidPath = errors.New("barrier: invalid path")
```
**Files to change**: `engines/sshca.md` func validatePath(p string) error {
for _, seg := range strings.Split(p, "/") {
### #26 — KRL section type description error if seg == ".." {
return fmt.Errorf("%w: path traversal rejected: %q", ErrInvalidPath, p)
**Fix**: Change the description block from: }
}
``` return nil
Section type: KRL_SECTION_CERT_SERIAL_LIST (0x21)
```
to:
```
Section type: KRL_SECTION_CERTIFICATES (0x01)
CA key blob: ssh.MarshalAuthorizedKey(caSigner.PublicKey())
Subsection type: KRL_SECTION_CERT_SERIAL_LIST (0x20)
```
This matches the pseudocode comments and the OpenSSH `PROTOCOL.krl` spec.
**Files to change**: `engines/sshca.md`
### #27 — Policy check after cert construction (SSH CA)
**Fix**: Reorder the sign-host flow steps:
1. Authenticate caller.
2. Parse the supplied SSH public key.
3. Parse TTL.
4. **Policy check**: for each hostname, check policy on
`sshca/{mount}/id/{hostname}`, action `sign`.
5. Generate serial (only after policy passes).
6. Build `ssh.Certificate`.
7. Sign, store, return.
Same reordering for sign-user.
**Files to change**: `engines/sshca.md`
### #29 — `rewrap` policy action not specified
**Fix**: Add `rewrap` as an explicit action in the `operationAction` mapping.
`rewrap` maps to `decrypt` (since it requires internal access to plaintext).
Batch variants map to the same action.
Add to the authorization section in `engines/transit.md`:
> The `rewrap` and `batch-rewrap` operations require the `decrypt` action —
> rewrap internally decrypts with the old version and re-encrypts with the
> latest, so the caller must have decrypt permission. Alternatively, a
> dedicated `rewrap` action could be added for finer-grained control, but
> `decrypt` is the safer default (granting `rewrap` without `decrypt` would be
> odd since rewrap implies decrypt capability).
**Recommendation**: Map to `decrypt`. Simpler, and anyone who should rewrap
should also be able to decrypt.
**Files to change**: `engines/transit.md`
### #31 — Missing `get-public-key` REST route (Transit)
**Fix**: Add to the REST endpoints table:
```
| GET | `/v1/transit/{mount}/keys/{name}/public-key` | Get public key |
```
Add to the route registration code block:
```go
r.Get("/v1/transit/{mount}/keys/{name}/public-key", s.requireAuth(s.handleTransitGetPublicKey))
```
**Files to change**: `engines/transit.md`
### #34 — No recipient limit on encrypt (User)
**Fix**: Add a compile-time constant `maxRecipients = 100` to the user engine.
Reject requests exceeding this limit with `400 Bad Request` / `InvalidArgument`
before any ECDH computation.
Add to the encrypt flow in `engines/user.md` after step 1:
> Validate that `len(recipients) <= maxRecipients` (100). Reject with error if
> exceeded.
Add to the security considerations section.
**Files to change**: `engines/user.md`
---
## Low
### #32 — `exportable` flag with no export operation (Transit)
**Fix**: Add an `export-key` operation to the transit engine:
- Auth: User+Policy (action `read`).
- Only succeeds if the key's `exportable` flag is `true`.
- Returns raw key material (base64-encoded) for the current version only.
- Asymmetric keys: returns private key in PKCS8 PEM.
- Symmetric keys: returns raw key bytes, base64-encoded.
- Add to HandleRequest dispatch, gRPC service, REST endpoints.
Alternatively, if key export is never intended, remove the `exportable` flag
from `create-key` to avoid dead code. Given that transit is meant to keep keys
server-side, **removing the flag** may be the better choice. Document the
decision either way.
**Recommendation**: Remove `exportable`. Transit's entire value proposition is
that keys never leave the service. If export is needed for migration, a
dedicated admin-only `export-key` can be added later with appropriate audit
logging (#7).
**Files to change**: `engines/transit.md`
### #35 — No re-encryption support for user key rotation
**Fix**: Add a `re-encrypt` operation:
- Auth: User (self) — only the envelope recipient can re-encrypt.
- Input: old envelope.
- Flow: decrypt with current key, generate new DEK, re-encrypt, return new
envelope.
- The old key must still be valid at the time of re-encryption. Document the
workflow: re-encrypt all stored envelopes, then rotate-key.
This is a quality-of-life improvement, not a security fix. The current design
(decrypt + encrypt separately) works but requires the caller to handle
plaintext.
**Files to change**: `engines/user.md`
### #36 — `UserKeyConfig` type undefined
**Fix**: Add the type definition to the in-memory state section:
```go
type UserKeyConfig struct {
Algorithm string `json:"algorithm"` // key exchange algorithm used
CreatedAt time.Time `json:"created_at"`
AutoProvisioned bool `json:"auto_provisioned"` // created via auto-provision
} }
``` ```
**Files to change**: `engines/user.md` Call `validatePath` at the entry of `Get`, `Put`, `Delete`, `List`.
Return `ErrInvalidPath` on failure.
### #38 — `ZeroizeKey` prerequisite not cross-referenced 2. **Engine/registry layer** — validate entity names at input boundaries.
**Fix**: Add to the Implementation Steps section in both `engines/transit.md` Add a `ValidateName` helper to `internal/engine/`:
and `engines/user.md`:
> **Prerequisite**: `engine.ZeroizeKey` must exist in ```go
> `internal/engine/helpers.go` (created as part of the SSH CA engine var namePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
> implementation — see `engines/sshca.md` step 1).
**Files to change**: `engines/transit.md`, `engines/user.md` func ValidateName(name string) error {
if name == "" || len(name) > 128 || !namePattern.MatchString(name) {
return fmt.Errorf("invalid name %q: must be 1-128 alphanumeric, "+
"dot, hyphen, or underscore characters", name)
}
return nil
}
```
Call `ValidateName` in:
| Location | Input validated |
|----------|----------------|
| `engine.go` `Mount()` | mount name |
| `ca.go` `handleCreateIssuer` | issuer name |
| `sshca.go` `handleCreateProfile` | profile name |
| `transit.go` `handleCreateKey` | key name |
| `user.go` `handleRegister`, `handleProvision` | username |
| `user.go` `handleEncrypt` | recipient usernames |
| `policy.go` `CreateRule` | rule ID |
Note: certificate serials are generated server-side from `crypto/rand`
and hex-encoded, so they are safe. Validate anyway for defense in depth.
### Files
| File | Change |
|------|--------|
| `internal/barrier/barrier.go` | Add `validatePath`, call from Get/Put/Delete/List |
| `internal/engine/engine.go` | Add `ValidateName`, call from `Mount` |
| `internal/engine/ca/ca.go` | Call `ValidateName` on issuer name |
| `internal/engine/sshca/sshca.go` | Call `ValidateName` on profile name |
| `internal/engine/transit/transit.go` | Call `ValidateName` on key name |
| `internal/engine/user/user.go` | Call `ValidateName` on usernames |
| `internal/policy/policy.go` | Call `ValidateName` on rule ID |
### Verification
- Add `TestValidatePath` to `barrier_test.go`: confirm `../` and `..` are
rejected; confirm normal paths pass.
- Add `TestValidateName` to `engine_test.go`: confirm `../evil`, empty
string, and overlong names are rejected; confirm valid names pass.
- `go test ./internal/barrier/ ./internal/engine/... ./internal/policy/`
---
## Work Item 3: Barrier Concurrency and Crash Safety (#39, #40)
These two findings share the barrier/seal subsystem and should be addressed
together.
### #39 — TOCTOU Race in Barrier Get/Put
**Risk**: `Get` and `Put` copy the `mek` slice header and `keys` map
reference under `RLock`, release the lock, then use the copied references
for encryption/decryption. A concurrent `Seal()` zeroizes the underlying
byte slices in place before nil-ing the fields, so a concurrent reader
uses zeroized key material.
**Root cause**: The lock does not cover the crypto operation. The "copy"
is a shallow reference copy (slice header), not a deep byte copy. `Seal()`
zeroizes the backing array, which is shared.
**Current locking pattern** (`barrier.go`):
```
Get: RLock → copy mek/keys refs → RUnlock → decrypt (uses zeroized key)
Put: RLock → copy mek/keys refs → RUnlock → encrypt (uses zeroized key)
Seal: Lock → zeroize mek bytes → nil mek → zeroize keys → nil keys → Unlock
```
**Fix**: Hold `RLock` through the entire crypto operation:
```go
func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
if err := validatePath(path); err != nil {
return nil, err
}
b.mu.RLock()
defer b.mu.RUnlock()
if b.mek == nil {
return nil, ErrSealed
}
// query DB, resolve key, decrypt — all under RLock
// ...
}
```
This is the minimal, safest change. `RLock` permits concurrent readers, so
there is no throughput regression for parallel `Get`/`Put` operations. The
only serialization point is `Seal()`, which acquires the exclusive `Lock`
and waits for all readers to drain — exactly the semantics we want.
Apply the same pattern to `Put`, `Delete`, and `List`.
**Alternative considered**: Atomic pointer swap (`atomic.Pointer[keyState]`).
This eliminates the lock from the hot path entirely, but introduces
complexity around deferred zeroization of the old state (readers may still
hold references). The `RLock`-through-crypto approach is simpler and
sufficient for Metacrypt's concurrency profile.
### #40 — Crash During `ReWrapKeys` Loses All Data
**Risk**: `RotateMEK` calls `barrier.ReWrapKeys(newMEK)` which commits a
transaction re-wrapping all DEKs, then separately updates `seal_config`
with the new encrypted MEK. A crash between these two database operations
leaves DEKs wrapped with a MEK that is not persisted — all data is
irrecoverable.
**Current flow** (`seal.go` lines 245313):
```
1. Generate newMEK
2. barrier.ReWrapKeys(ctx, newMEK) ← commits transaction (barrier_keys updated)
3. crypto.Encrypt(kwk, newMEK, nil) ← encrypt new MEK
4. UPDATE seal_config SET encrypted_mek = ? ← separate statement, not in transaction
*** CRASH HERE = DATA LOSS ***
5. Swap in-memory MEK
```
**Fix**: Unify steps 24 into a single database transaction.
Refactor `ReWrapKeys` to accept an optional `*sql.Tx`:
```go
// ReWrapKeysTx re-wraps all DEKs with newMEK within the given transaction.
func (b *AESGCMBarrier) ReWrapKeysTx(ctx context.Context, tx *sql.Tx, newMEK []byte) error {
// Same logic as ReWrapKeys, but use tx instead of b.db.BeginTx.
rows, err := tx.QueryContext(ctx, "SELECT key_id, wrapped_key FROM barrier_keys")
// ... decrypt with old MEK, encrypt with new MEK, UPDATE barrier_keys ...
}
// SwapMEK updates the in-memory MEK after a committed transaction.
func (b *AESGCMBarrier) SwapMEK(newMEK []byte) {
b.mu.Lock()
defer b.mu.Unlock()
mcrypto.Zeroize(b.mek)
b.mek = newMEK
}
```
Then in `RotateMEK`:
```go
func (m *Manager) RotateMEK(ctx context.Context, password string) error {
// ... derive KWK, generate newMEK ...
tx, err := m.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Re-wrap all DEKs within the transaction.
if err := m.barrier.ReWrapKeysTx(ctx, tx, newMEK); err != nil {
return err
}
// Update seal_config within the same transaction.
encNewMEK, err := crypto.Encrypt(kwk, newMEK, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
"UPDATE seal_config SET encrypted_mek = ? WHERE id = 1",
encNewMEK,
); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
// Only after commit: update in-memory state.
m.barrier.SwapMEK(newMEK)
return nil
}
```
SQLite in WAL mode handles this correctly — the transaction is atomic
regardless of process crash. The `barrier_keys` and `seal_config` updates
either both commit or neither does.
### Files
| File | Change |
|------|--------|
| `internal/barrier/barrier.go` | Extend RLock scope in Get/Put/Delete/List; add `ReWrapKeysTx`, `SwapMEK` |
| `internal/seal/seal.go` | Wrap ReWrapKeysTx + seal_config UPDATE in single transaction |
| `internal/barrier/barrier_test.go` | Add concurrent Get/Seal stress test |
### Verification
- `go test -race ./internal/barrier/ ./internal/seal/`
- Add `TestConcurrentGetSeal`: spawn goroutines doing Get while another
goroutine calls Seal. Run with `-race`. Verify no panics or data races.
- Add `TestRotateMEKAtomic`: verify that `barrier_keys` and `seal_config`
are updated in the same transaction (mock the DB to detect transaction
boundaries, or verify via rollback behavior).
---
## Work Item 4: CA TTL Enforcement, User Engine Fixes, Policy Bypass (#49, #61, #62, #69)
These four findings touch separate files with no overlap and can be
addressed in parallel.
### #49 — No TTL Ceiling in CA Certificate Issuance
**Risk**: A non-admin user can request an arbitrarily long certificate
lifetime. The issuer's `MaxTTL` exists in config but is not enforced
during `handleIssue` or `handleSignCSR`.
**Root cause**: The CA engine applies the user's requested TTL directly
to the certificate without comparing it against `issuerConfig.MaxTTL`.
The SSH CA engine correctly enforces this via `resolveTTL` — the CA
engine does not.
**Fix**: Add a `resolveTTL` method to the CA engine, following the SSH
CA engine's pattern (`sshca.go` lines 902932):
```go
func (e *CAEngine) resolveTTL(requested string, issuer *issuerState) (time.Duration, error) {
maxTTL, err := time.ParseDuration(issuer.config.MaxTTL)
if err != nil {
maxTTL = 2160 * time.Hour // 90 days fallback
}
if requested != "" {
ttl, err := time.ParseDuration(requested)
if err != nil {
return 0, fmt.Errorf("invalid TTL: %w", err)
}
if ttl > maxTTL {
return 0, fmt.Errorf("requested TTL %s exceeds issuer maximum %s", ttl, maxTTL)
}
return ttl, nil
}
return maxTTL, nil
}
```
Call this in `handleIssue` and `handleSignCSR` before constructing the
certificate. Replace the raw TTL string with the validated duration.
| File | Change |
|------|--------|
| `internal/engine/ca/ca.go` | Add `resolveTTL`, call from `handleIssue` and `handleSignCSR` |
| `internal/engine/ca/ca_test.go` | Add test: issue cert with TTL > MaxTTL, verify rejection |
### #61 — Ineffective ECDH Key Zeroization
**Risk**: `privKey.Bytes()` returns a copy of the private key bytes.
Zeroizing the copy leaves the original inside `*ecdh.PrivateKey`. Go's
`crypto/ecdh` API does not expose the internal byte slice.
**Root cause**: Language/API limitation in Go's `crypto/ecdh` package.
**Fix**: Store the raw private key bytes alongside the parsed key in
`userState`, and zeroize those bytes on seal:
```go
type userState struct {
privKey *ecdh.PrivateKey
privBytes []byte // raw key bytes, retained for zeroization
pubKey *ecdh.PublicKey
config *UserKeyConfig
}
```
On **load from barrier** (Unseal, auto-provision):
```go
raw, err := b.Get(ctx, prefix+"priv.key")
priv, err := curve.NewPrivateKey(raw)
state.privBytes = raw // retain for zeroization
state.privKey = priv
```
On **Seal**:
```go
mcrypto.Zeroize(u.privBytes)
u.privKey = nil
u.privBytes = nil
```
Document the limitation: the parsed `*ecdh.PrivateKey` struct's internal
copy cannot be zeroized from Go code. Setting `privKey = nil` makes it
eligible for GC, but does not guarantee immediate byte overwrite. This is
an accepted Go runtime limitation.
| File | Change |
|------|--------|
| `internal/engine/user/user.go` | Add `privBytes` to `userState`, populate on load, zeroize on Seal |
| `internal/engine/user/types.go` | Update `userState` struct |
### #62 — User Engine Policy Path Uses `mountPath` Instead of Mount Name
**Risk**: Policy checks construct the resource path using `e.mountPath`
(which is `engine/user/{name}/`) instead of just the mount name. Policy
rules match against `user/{name}/recipient/{username}`, so the full mount
path creates a mismatch like `user/engine/user/myengine//recipient/alice`.
No policy rule will ever match.
**Root cause**: Line 358 of `user.go` uses `e.mountPath` directly. The
SSH CA and transit engines correctly use a `mountName()` helper.
**Fix**: Add a `mountName()` method to the user engine:
```go
func (e *UserEngine) mountName() string {
// mountPath is "engine/user/{name}/"
parts := strings.Split(strings.TrimSuffix(e.mountPath, "/"), "/")
if len(parts) >= 3 {
return parts[2]
}
return e.mountPath
}
```
Change line 358:
```go
resource := fmt.Sprintf("user/%s/recipient/%s", e.mountName(), r)
```
Audit all other resource path constructions in the user engine to confirm
they also use the correct mount name.
| File | Change |
|------|--------|
| `internal/engine/user/user.go` | Add `mountName()`, fix resource path on line 358 |
| `internal/engine/user/user_test.go` | Add test: verify policy resource path format |
### #69 — Typed REST Handlers Bypass Policy Engine
**Risk**: 18 typed REST handlers pass `nil` for `CheckPolicy` in the
`engine.Request`, skipping service-level policy evaluation. The generic
`/v1/engine/request` endpoint correctly passes a `policyChecker`. Since
engines #54 and #58 default to allow when no policy matches, typed routes
are effectively unprotected by policy.
**Root cause**: Typed handlers were modeled after admin-only operations
(which don't need policy) but applied to user-accessible operations.
**Fix**: Extract the policy checker construction from
`handleEngineRequest` into a shared helper:
```go
func (s *Server) newPolicyChecker(info *CallerInfo) engine.PolicyChecker {
return func(resource, action string) (string, bool) {
effect, matched, err := s.policy.Check(
info.Username, info.Roles, resource, action,
)
if err != nil || !matched {
return "deny", false
}
return effect, matched
}
}
```
Then in each typed handler, set `CheckPolicy` on the request:
```go
req := &engine.Request{
Operation: "get-cert",
Data: data,
CallerInfo: callerInfo,
CheckPolicy: s.newPolicyChecker(callerInfo),
}
```
**18 handlers to update**:
| Handler | Operation |
|---------|-----------|
| `handleGetCert` | `get-cert` |
| `handleRevokeCert` | `revoke-cert` |
| `handleDeleteCert` | `delete-cert` |
| `handleSSHCASignHost` | `sign-host` |
| `handleSSHCASignUser` | `sign-user` |
| `handleSSHCAGetProfile` | `get-profile` |
| `handleSSHCAListProfiles` | `list-profiles` |
| `handleSSHCADeleteProfile` | `delete-profile` |
| `handleSSHCAGetCert` | `get-cert` |
| `handleSSHCAListCerts` | `list-certs` |
| `handleSSHCARevokeCert` | `revoke-cert` |
| `handleSSHCADeleteCert` | `delete-cert` |
| `handleUserRegister` | `register` |
| `handleUserProvision` | `provision` |
| `handleUserListUsers` | `list-users` |
| `handleUserGetPublicKey` | `get-public-key` |
| `handleUserDeleteUser` | `delete-user` |
| `handleUserDecrypt` | `decrypt` |
Note: `handleUserEncrypt` already passes a policy checker — verify it
uses the same shared helper after refactoring. Admin-only handlers
(behind `requireAdmin` wrapper) do not need a policy checker since admin
bypasses policy.
| File | Change |
|------|--------|
| `internal/server/routes.go` | Add `newPolicyChecker`, pass to all 18 typed handlers |
| `internal/server/server_test.go` | Add test: policy-denied user is rejected by typed route |
### Verification (Work Item 4, all findings)
```bash
go test ./internal/engine/ca/
go test ./internal/engine/user/
go test ./internal/server/
go vet ./...
```
--- ---
## Implementation Order ## Implementation Order
The remediation items should be implemented in this order to respect ```
dependencies: 1. #68 JSON injection (standalone, ship immediately)
2. #48 Path traversal (standalone, blocks safe engine operation)
3. #39 Barrier TOCTOU race ─┐
#40 ReWrapKeys crash safety ┘ (coupled, requires careful testing)
4. #49 CA TTL enforcement ─┐
#61 ECDH zeroization │
#62 User policy path │ (independent fixes, parallelizable)
#69 Policy bypass ─┘
```
1. **#37** — `adminOnlyOperations` qualification (critical, blocks user engine Items 1 and 2 have no dependencies and can be done in parallel by
`rotate-key`). This is a code change to `internal/server/routes.go` plus different engineers.
spec updates. Do first because it affects all engine implementations.
2. **#28, #29, #30, #31, #32** — Transit spec fixes (can be done as a single Items 3 and 4 can also be done in parallel since they touch different
spec update pass). subsystems (barrier/seal vs engines/server).
3. **#25, #26, #27** — SSH CA spec fixes (single spec update pass). ---
4. **#33, #34, #35, #36** — User spec fixes (single spec update pass). ## Post-Remediation
5. **#38** — Cross-reference update (trivial, do with transit and user spec After all eight findings are resolved:
fixes).
Items within the same group are independent and can be done in parallel. 1. **Update AUDIT.md** — mark #39, #40, #48, #49, #61, #62, #68, #69 as
RESOLVED with resolution summaries.
2. **Run the full pipeline**: `make all` (vet, lint, test, build).
3. **Run race detector**: `go test -race ./...`
4. **Address related medium findings** that interact with these fixes:
- #54 (SSH CA default-allow) and #58 (transit default-allow) — once
#69 is fixed, the typed handlers will pass policy checkers to the
engines, but the engines still default-allow when `CheckPolicy`
returns no match. Consider changing the engine-level default to deny
for non-admin callers.
- #72 (policy ID path traversal) — already covered by #48's
`ValidateName` fix on `CreateRule`.

844
engines/webui.md Normal file
View File

@@ -0,0 +1,844 @@
# Web UI Implementation Plan: SSH CA, Transit, and User Engines
## Overview
Three engines (SSH CA, Transit, User) are fully implemented at the core,
gRPC, and REST layers but have no web UI. This plan adds browser-based
management for each, following the patterns established by the PKI engine UI.
## Architecture
The web UI is served by `metacrypt-web`, a separate binary that talks to the
API server over gRPC. All data access flows through the gRPC client — the web
server has no direct database or barrier access. Authorization is enforced by
the API server; the web UI only controls visibility (e.g. hiding admin-only
forms from non-admin users).
### Existing Patterns (from PKI reference)
| Concern | Pattern |
|---------|---------|
| Template composition | `layout.html` defines `"layout"` block; page templates define `"title"` and `"content"` blocks |
| Template rendering | `renderTemplate(w, "page.html", data)` — parses `layout.html` + page template, injects CSRF func |
| gRPC calls | `ws.vault.Method(ctx, token)` via `vaultBackend` interface; token from cookie |
| CSRF | Signed double-submit cookie; `{{csrfField}}` in every form |
| Error display | `{{if .Error}}<div class="error">{{.Error}}</div>{{end}}` at top of content |
| Success display | `<div class="success">...</div>` inline after action |
| Mount discovery | `findCAMount()` pattern — iterate `ListMounts()`, match on `.Type` |
| Tables | `.table-wrapper` > `<table>` with `<thead>`/`<tbody>` |
| Detail views | `.card` with `.card-title` + `.kv-table` for metadata |
| Admin actions | `{{if .IsAdmin}}` guards around admin-only cards |
| Forms | `.form-row` > `.form-group` > `<label>` + `<input>` |
| Navigation | Breadcrumb in `.page-meta`: `← Dashboard` |
---
## Shared Changes
### 1. Navigation (layout.html)
Add engine links to the top nav, conditionally displayed based on mounted
engines. The template data already includes `Username` and `IsAdmin`; extend
it with engine availability flags.
```html
<!-- After PKI link -->
{{if .HasSSHCA}}<a href="/sshca" class="btn btn-ghost btn-sm">SSH CA</a>{{end}}
{{if .HasTransit}}<a href="/transit" class="btn btn-ghost btn-sm">Transit</a>{{end}}
{{if .HasUser}}<a href="/user" class="btn btn-ghost btn-sm">User Crypto</a>{{end}}
```
To populate these flags without an extra gRPC call per page, cache mount
types in the web server after the first `ListMounts()` call per request (the
`requireAuth` middleware already validates the token — extend it to also
populate a `mountTypes` set on the request context).
### 2. Dashboard (dashboard.html)
Extend the mount table to link all engine types, not just CA:
```html
{{if eq (printf "%s" .Type) "ca"}}
<a href="/pki">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "sshca"}}
<a href="/sshca">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "transit"}}
<a href="/transit">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "user"}}
<a href="/user">{{.Name}}</a>
{{else}}
{{.Name}}
{{end}}
```
Add mount forms for the three new engine types (admin only), following the
existing `<details><summary>Mount a CA engine</summary>` pattern.
### 3. gRPC Client (client.go)
Add gRPC service clients to `VaultClient`:
```go
type VaultClient struct {
// ... existing fields ...
sshca pb.SSHCAServiceClient
transit pb.TransitServiceClient
user pb.UserServiceClient
}
```
Add wrapper request/response types for each engine (see per-engine sections
below). Follow the existing pattern: thin structs that translate between
protobuf and template-friendly Go types.
### 4. vaultBackend Interface (server.go)
Add methods for each engine to the `vaultBackend` interface. Group by engine.
### 5. Mount Helpers
Add `findSSHCAMount()`, `findTransitMount()`, `findUserMount()` following
the `findCAMount()` pattern.
---
## SSH CA Engine Web UI
### Route: `/sshca`
File: `internal/webserver/routes.go` — register under `r.Route("/sshca", ...)`
| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/sshca` | `handleSSHCA` | User |
| POST | `/sshca/sign-user` | `handleSSHCASignUser` | User |
| POST | `/sshca/sign-host` | `handleSSHCASignHost` | User |
| GET | `/sshca/cert/{serial}` | `handleSSHCACertDetail` | User |
| POST | `/sshca/cert/{serial}/revoke` | `handleSSHCACertRevoke` | Admin |
| POST | `/sshca/cert/{serial}/delete` | `handleSSHCACertDelete` | Admin |
| POST | `/sshca/profile/create` | `handleSSHCACreateProfile` | Admin |
| GET | `/sshca/profile/{name}` | `handleSSHCAProfileDetail` | User |
| POST | `/sshca/profile/{name}/update` | `handleSSHCAUpdateProfile` | Admin |
| POST | `/sshca/profile/{name}/delete` | `handleSSHCADeleteProfile` | Admin |
### Template: `sshca.html`
Main page for the SSH CA engine. Structure:
```
┌─────────────────────────────────────────┐
│ SSH CA: {mount} ← Dashboard│
├─────────────────────────────────────────┤
│ CA Public Key │
│ ┌─────────────────────────────────────┐ │
│ │ Algorithm: ed25519 │ │
│ │ Fingerprint: SHA256:xxxx │ │
│ │ [Download Public Key] │ │
│ │ ssh-ed25519 AAAA... (readonly area) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Sign User Certificate │
│ ┌─────────────────────────────────────┐ │
│ │ Profile: [select] │ │
│ │ Public Key: [textarea, ssh format] │ │
│ │ Principals: [textarea, one/line] │ │
│ │ TTL: [input, optional] │ │
│ │ [Sign Certificate] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Sign Host Certificate │
│ ┌─────────────────────────────────────┐ │
│ │ Profile: [select] │ │
│ │ Public Key: [textarea, ssh format] │ │
│ │ Hostnames: [textarea, one/line] │ │
│ │ TTL: [input, optional] │ │
│ │ [Sign Certificate] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Signing Profiles │
│ ┌─────────────────────────────────────┐ │
│ │ Name │ Type │ Max TTL │ Actions │ │
│ │ ─────┼──────┼─────────┼──────────── │ │
│ │ eng │ user │ 8760h │ [View] │ │
│ │ ops │ host │ 720h │ [View] │ │
│ └─────────────────────────────────────┘ │
│ [Create Profile] (admin, <details>) │
├─────────────────────────────────────────┤
│ Recent Certificates │
│ ┌─────────────────────────────────────┐ │
│ │ Serial │ Type │ Principals │ Issued │ │
│ │ │ │ │ By │ │
│ │ ───────┼──────┼────────────┼─────── │ │
│ │ abc123 │ user │ kyle │ kyle │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ KRL (admin only) │
│ ┌─────────────────────────────────────┐ │
│ │ Version: 7 │ │
│ │ [Download KRL] │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
### Template: `sshca_cert_detail.html`
Detail view for a single SSH certificate:
```
┌─────────────────────────────────────────┐
│ SSH Certificate: {serial} ← SSH CA │
├─────────────────────────────────────────┤
│ Details │
│ ┌─────────────────────────────────────┐ │
│ │ Type: user │ │
│ │ Serial: abc123 │ │
│ │ Key ID: kyle@host │ │
│ │ Principals: kyle, root │ │
│ │ Profile: eng │ │
│ │ Valid After: 2026-03-16T... │ │
│ │ Valid Before: 2026-03-17T... │ │
│ │ Issued By: kyle │ │
│ │ Issued At: 2026-03-16T... │ │
│ │ Revoked: No │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Certificate (readonly textarea) │
├─────────────────────────────────────────┤
│ Admin Actions (admin only) │
│ [Revoke] [Delete] │
└─────────────────────────────────────────┘
```
### Template: `sshca_profile_detail.html`
Detail view for a signing profile:
```
┌─────────────────────────────────────────┐
│ Profile: {name} ← SSH CA │
├─────────────────────────────────────────┤
│ Configuration │
│ ┌─────────────────────────────────────┐ │
│ │ Name: eng │ │
│ │ Type: user │ │
│ │ Max TTL: 8760h │ │
│ │ Default TTL: 24h │ │
│ │ Allowed Principals: * │ │
│ │ Force Command: (none) │ │
│ │ Source Addresses: (none) │ │
│ │ Allow Agent Fwd: yes │ │
│ │ Allow Port Fwd: yes │ │
│ │ Allow PTY: yes │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Edit Profile (admin only, <details>) │
│ ┌─────────────────────────────────────┐ │
│ │ (form with pre-populated fields) │ │
│ │ [Update Profile] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Admin Actions (admin only) │
│ [Delete Profile] │
└─────────────────────────────────────────┘
```
### Handler File: `internal/webserver/sshca.go`
Handlers for all SSH CA web routes. Follow `pki.go` patterns:
- `handleSSHCA` — main page: fetch CA pubkey, profiles, recent certs; render
- `handleSSHCASignUser` / `handleSSHCASignHost` — parse form, call gRPC,
render success inline (show signed cert in readonly textarea) or re-render
with error
- `handleSSHCACertDetail` — fetch cert by serial, render detail page
- `handleSSHCACertRevoke` / `handleSSHCACertDelete` — admin action, redirect
back to main page
- `handleSSHCACreateProfile` — admin form, redirect to profile detail
- `handleSSHCAProfileDetail` — fetch profile, render detail page
- `handleSSHCAUpdateProfile` — admin form, redirect to profile detail
- `handleSSHCADeleteProfile` — admin action, redirect to main page
### gRPC Client Methods
```go
// client.go additions
GetSSHCAPublicKey(ctx, token, mount string) (*SSHCAPublicKey, error)
SSHCASignUser(ctx, token, mount string, req *SSHCASignRequest) (*SSHCACert, error)
SSHCASignHost(ctx, token, mount string, req *SSHCASignRequest) (*SSHCACert, error)
ListSSHCACerts(ctx, token, mount string) ([]SSHCACertSummary, error)
GetSSHCACert(ctx, token, mount, serial string) (*SSHCACertDetail, error)
RevokeSSHCACert(ctx, token, mount, serial string) error
DeleteSSHCACert(ctx, token, mount, serial string) error
CreateSSHCAProfile(ctx, token, mount string, req *SSHCAProfileRequest) error
GetSSHCAProfile(ctx, token, mount, name string) (*SSHCAProfile, error)
ListSSHCAProfiles(ctx, token, mount string) ([]SSHCAProfileSummary, error)
UpdateSSHCAProfile(ctx, token, mount, name string, req *SSHCAProfileRequest) error
DeleteSSHCAProfile(ctx, token, mount, name string) error
GetSSHCAKRL(ctx, token, mount string) ([]byte, uint64, error)
```
### Wrapper Types
```go
type SSHCAPublicKey struct {
Algorithm string
PublicKey string // authorized_keys format
Fingerprint string
}
type SSHCASignRequest struct {
PublicKey string
Principals []string
TTL string
Profile string
}
type SSHCACert struct {
Serial string
Certificate string // SSH certificate text
}
type SSHCACertSummary struct {
Serial string
Type string // "user" or "host"
Principals string // comma-joined
IssuedBy string
IssuedAt string
ExpiresAt string
Revoked bool
}
type SSHCACertDetail struct {
SSHCACertSummary
KeyID string
Profile string
Certificate string // full cert text
}
type SSHCAProfileSummary struct {
Name string
Type string
MaxTTL string
DefaultTTL string
}
type SSHCAProfile struct {
SSHCAProfileSummary
AllowedPrincipals []string
ForceCommand string
SourceAddresses []string
AllowAgentFwd bool
AllowPortFwd bool
AllowPTY bool
}
type SSHCAProfileRequest struct {
Name string
Type string // "user" or "host"
MaxTTL string
DefaultTTL string
AllowedPrincipals []string
ForceCommand string
SourceAddresses []string
AllowAgentFwd bool
AllowPortFwd bool
AllowPTY bool
}
```
---
## Transit Engine Web UI
### Route: `/transit`
| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/transit` | `handleTransit` | User |
| GET | `/transit/key/{name}` | `handleTransitKeyDetail` | User |
| POST | `/transit/key/create` | `handleTransitCreateKey` | Admin |
| POST | `/transit/key/{name}/rotate` | `handleTransitRotateKey` | Admin |
| POST | `/transit/key/{name}/config` | `handleTransitUpdateConfig` | Admin |
| POST | `/transit/key/{name}/trim` | `handleTransitTrimKey` | Admin |
| POST | `/transit/key/{name}/delete` | `handleTransitDeleteKey` | Admin |
| POST | `/transit/encrypt` | `handleTransitEncrypt` | User |
| POST | `/transit/decrypt` | `handleTransitDecrypt` | User |
| POST | `/transit/rewrap` | `handleTransitRewrap` | User |
| POST | `/transit/sign` | `handleTransitSign` | User |
| POST | `/transit/verify` | `handleTransitVerify` | User |
| POST | `/transit/hmac` | `handleTransitHMAC` | User |
### Template: `transit.html`
Main page. Structure:
```
┌─────────────────────────────────────────┐
│ Transit: {mount} ← Dashboard│
├─────────────────────────────────────────┤
│ Named Keys │
│ ┌─────────────────────────────────────┐ │
│ │ Name │ Type │ Versions │ │
│ │ ─────────┼────────────┼─────────── │ │
│ │ payments │ aes256-gcm │ 3 │ │
│ │ signing │ ed25519 │ 1 │ │
│ └─────────────────────────────────────┘ │
│ [Create Key] (admin, <details>) │
│ Name: [input] │
│ Type: [select: aes256-gcm, etc.] │
│ [Create] │
├─────────────────────────────────────────┤
│ Encrypt │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select from named keys] │ │
│ │ Plaintext (base64): [textarea] │ │
│ │ Context (optional): [input] │ │
│ │ [Encrypt] │ │
│ │ │ │
│ │ Result (if present): │ │
│ │ metacrypt:v1:xxxxx (readonly area) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Decrypt │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select] │ │
│ │ Ciphertext: [textarea] │ │
│ │ Context (optional): [input] │ │
│ │ [Decrypt] │ │
│ │ │ │
│ │ Result (if present): │ │
│ │ base64 plaintext (readonly area) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Rewrap │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select] │ │
│ │ Ciphertext: [textarea] │ │
│ │ Context (optional): [input] │ │
│ │ [Rewrap] │ │
│ │ │ │
│ │ Result: (new ciphertext) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Sign │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select, signing keys only] │ │
│ │ Input (base64): [textarea] │ │
│ │ [Sign] │ │
│ │ │ │
│ │ Result: metacrypt:v1:sig (readonly) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Verify │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select, signing keys only] │ │
│ │ Input (base64): [textarea] │ │
│ │ Signature: [textarea] │ │
│ │ [Verify] │ │
│ │ │ │
│ │ Result: Valid ✓ / Invalid ✗ │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ HMAC │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select, HMAC keys only] │ │
│ │ Input (base64): [textarea] │ │
│ │ [Generate HMAC] │ │
│ │ │ │
│ │ Result: metacrypt:v1:hmac (ro) │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
Note: Batch operations are API-only. The web UI does not expose batch
encrypt/decrypt/rewrap — those are designed for programmatic use.
### Template: `transit_key_detail.html`
```
┌─────────────────────────────────────────┐
│ Key: {name} ← Transit │
├─────────────────────────────────────────┤
│ Key Details │
│ ┌─────────────────────────────────────┐ │
│ │ Name: payments │ │
│ │ Type: aes256-gcm │ │
│ │ Latest Version: 3 │ │
│ │ Min Decrypt Version: 1 │ │
│ │ Min Encrypt Version: 3 │ │
│ │ Allow Deletion: no │ │
│ │ Exportable: no │ │
│ │ Max Key Versions: 0 (unlimited) │ │
│ │ Created: 2026-03-16... │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Key Versions │
│ ┌─────────────────────────────────────┐ │
│ │ Version │ Created │ │
│ │ ────────┼────────────────────────── │ │
│ │ 3 │ 2026-03-16T10:00:00Z │ │
│ │ 2 │ 2026-03-10T08:00:00Z │ │
│ │ 1 │ 2026-03-01T12:00:00Z │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Public Key (signing keys only) │
│ [textarea, PEM, readonly] │
├─────────────────────────────────────────┤
│ Admin Actions (admin only) │
│ [Rotate Key] │
│ │
│ Update Config (<details>) │
│ Min Decrypt Version: [input] │
│ Allow Deletion: [checkbox] │
│ [Update] │
│ │
│ Trim Versions (<details>) │
│ Keep from version: [input] │
│ [Trim] │
│ │
│ [Delete Key] (danger, requires confirm) │
└─────────────────────────────────────────┘
```
### Handler File: `internal/webserver/transit.go`
- `handleTransit` — list keys, render main page
- `handleTransitKeyDetail` — fetch key metadata + versions, render detail
- `handleTransitCreateKey` — parse form, call gRPC, redirect to key detail
- `handleTransitRotateKey` — admin, redirect to key detail
- `handleTransitUpdateConfig` — admin, redirect to key detail
- `handleTransitTrimKey` — admin, redirect to key detail
- `handleTransitDeleteKey` — admin, redirect to main page
- `handleTransitEncrypt` — parse form, call gRPC, re-render main page with
result in `EncryptResult` field
- `handleTransitDecrypt` — same pattern, `DecryptResult` field
- `handleTransitRewrap` — same pattern, `RewrapResult` field
- `handleTransitSign` — same pattern, `SignResult` field
- `handleTransitVerify` — same pattern, `VerifyResult` field (boolean)
- `handleTransitHMAC` — same pattern, `HMACResult` field
### gRPC Client Methods
```go
ListTransitKeys(ctx, token, mount string) ([]TransitKeySummary, error)
GetTransitKey(ctx, token, mount, name string) (*TransitKeyDetail, error)
CreateTransitKey(ctx, token, mount string, req *CreateTransitKeyRequest) error
DeleteTransitKey(ctx, token, mount, name string) error
RotateTransitKey(ctx, token, mount, name string) error
UpdateTransitKeyConfig(ctx, token, mount, name string, req *UpdateTransitKeyConfigRequest) error
TrimTransitKey(ctx, token, mount, name string, minVersion int) error
TransitEncrypt(ctx, token, mount, key string, req *TransitEncryptRequest) (string, error)
TransitDecrypt(ctx, token, mount, key string, req *TransitDecryptRequest) (string, error)
TransitRewrap(ctx, token, mount, key string, req *TransitRewrapRequest) (string, error)
TransitSign(ctx, token, mount, key string, input string) (string, error)
TransitVerify(ctx, token, mount, key string, input, signature string) (bool, error)
TransitHMAC(ctx, token, mount, key string, input string) (string, error)
GetTransitPublicKey(ctx, token, mount, name string) (string, error)
```
### Wrapper Types
```go
type TransitKeySummary struct {
Name string
Type string
LatestVersion int
}
type TransitKeyVersion struct {
Version int
CreatedAt string
}
type TransitKeyDetail struct {
Name string
Type string
LatestVersion int
MinDecryptVersion int
MinEncryptVersion int
AllowDeletion bool
Exportable bool
MaxKeyVersions int
CreatedAt string
Versions []TransitKeyVersion
PublicKeyPEM string // empty for symmetric keys
}
type CreateTransitKeyRequest struct {
Name string
Type string // aes256-gcm, chacha20-poly, ed25519, etc.
}
type UpdateTransitKeyConfigRequest struct {
MinDecryptVersion int
AllowDeletion *bool // nil = no change
}
type TransitEncryptRequest struct {
Plaintext string // base64
Context string // optional AAD
}
type TransitDecryptRequest struct {
Ciphertext string // metacrypt:v1:...
Context string
}
type TransitRewrapRequest struct {
Ciphertext string
Context string
}
```
---
## User Engine Web UI
### Route: `/user`
| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/user` | `handleUser` | User |
| POST | `/user/register` | `handleUserRegister` | User |
| POST | `/user/provision` | `handleUserProvision` | Admin |
| GET | `/user/key/{username}` | `handleUserKeyDetail` | User |
| POST | `/user/encrypt` | `handleUserEncrypt` | User |
| POST | `/user/decrypt` | `handleUserDecrypt` | User |
| POST | `/user/re-encrypt` | `handleUserReEncrypt` | User |
| POST | `/user/rotate` | `handleUserRotateKey` | User |
| POST | `/user/delete/{username}` | `handleUserDeleteUser` | Admin |
### Template: `user.html`
Main page. Structure:
```
┌─────────────────────────────────────────┐
│ User Crypto: {mount} ← Dashboard│
├─────────────────────────────────────────┤
│ Your Key │
│ ┌─────────────────────────────────────┐ │
│ │ (if registered) │ │
│ │ Algorithm: x25519 │ │
│ │ Public Key: base64... │ │
│ │ │ │
│ │ (if not registered) │ │
│ │ You have no keypair. │ │
│ │ [Register] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Encrypt │
│ ┌─────────────────────────────────────┐ │
│ │ Recipients: [textarea, one/line] │ │
│ │ Plaintext (base64): [textarea] │ │
│ │ Metadata (optional): [input] │ │
│ │ [Encrypt] │ │
│ │ │ │
│ │ Result (if present): │ │
│ │ JSON envelope (readonly textarea) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Decrypt │
│ ┌─────────────────────────────────────┐ │
│ │ Envelope (JSON): [textarea] │ │
│ │ [Decrypt] │ │
│ │ │ │
│ │ Result (if present): │ │
│ │ base64 plaintext (readonly) │ │
│ │ Metadata: ... (readonly) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Re-Encrypt │
│ ┌─────────────────────────────────────┐ │
│ │ Envelope (JSON): [textarea] │ │
│ │ [Re-Encrypt] │ │
│ │ │ │
│ │ Result: updated envelope (readonly) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Key Rotation (danger zone) │
│ ┌─────────────────────────────────────┐ │
│ │ ⚠ Rotating your key will make all │ │
│ │ existing envelopes unreadable │ │
│ │ unless re-encrypted first. │ │
│ │ [Rotate Key] (confirm dialog) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Registered Users │
│ ┌─────────────────────────────────────┐ │
│ │ Username │ Algorithm │ Actions │ │
│ │ ───────────┼───────────┼─────────── │ │
│ │ kyle │ x25519 │ [View Key] │ │
│ │ alice │ x25519 │ [View Key] │ │
│ └─────────────────────────────────────┘ │
│ [Provision User] (admin, <details>) │
│ Username: [input] │
│ [Provision] │
├─────────────────────────────────────────┤
│ Admin: Delete User (admin only) │
│ ┌─────────────────────────────────────┐ │
│ │ Username: [input] │ │
│ │ [Delete User] (danger, confirm) │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
### Template: `user_key_detail.html`
```
┌─────────────────────────────────────────┐
│ User Key: {username} ← User │
├─────────────────────────────────────────┤
│ Public Key │
│ ┌─────────────────────────────────────┐ │
│ │ Username: kyle │ │
│ │ Algorithm: x25519 │ │
│ │ Public Key: base64... │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Admin Actions (admin only) │
│ [Delete User] (danger, confirm) │
└─────────────────────────────────────────┘
```
### Handler File: `internal/webserver/user.go`
- `handleUser` — fetch own key (if registered), list users, render main page
- `handleUserRegister` — call gRPC, redirect to main page with success
- `handleUserProvision` — admin, call gRPC, redirect to main page
- `handleUserKeyDetail` — fetch public key for username, render detail
- `handleUserEncrypt` — parse form, call gRPC, re-render with envelope result
- `handleUserDecrypt` — parse form, call gRPC, re-render with plaintext result
- `handleUserReEncrypt` — parse form, call gRPC, re-render with new envelope
- `handleUserRotateKey` — confirm + call gRPC, redirect to main page
- `handleUserDeleteUser` — admin, call gRPC, redirect to main page
### gRPC Client Methods
```go
UserRegister(ctx, token, mount string) error
UserProvision(ctx, token, mount, username string) error
GetUserPublicKey(ctx, token, mount, username string) (*UserPublicKey, error)
ListUsers(ctx, token, mount string) ([]UserSummary, error)
UserEncrypt(ctx, token, mount string, req *UserEncryptRequest) (string, error)
UserDecrypt(ctx, token, mount string, envelope string) (*UserDecryptResult, error)
UserReEncrypt(ctx, token, mount string, envelope string) (string, error)
UserRotateKey(ctx, token, mount string) error
DeleteUser(ctx, token, mount, username string) error
```
### Wrapper Types
```go
type UserPublicKey struct {
Username string
Algorithm string
PublicKey string // base64
}
type UserSummary struct {
Username string
Algorithm string
}
type UserEncryptRequest struct {
Recipients []string
Plaintext string // base64
Metadata string // optional
}
type UserDecryptResult struct {
Plaintext string // base64
Metadata string
Sender string
}
```
---
## Implementation Order
Implement in this order to build incrementally on shared infrastructure:
### Phase 1: Shared Infrastructure
1. Add gRPC service clients to `VaultClient` struct
2. Add `findSSHCAMount()`, `findTransitMount()`, `findUserMount()` helpers
3. Update `layout.html` navigation with conditional engine links
4. Update `dashboard.html` mount table links and mount forms
5. Extend `requireAuth` middleware to populate mount availability flags
### Phase 2: SSH CA (closest to existing PKI patterns)
6. Implement SSH CA wrapper types in `client.go`
7. Implement SSH CA gRPC client methods in `client.go`
8. Add SSH CA methods to `vaultBackend` interface
9. Create `sshca.html`, `sshca_cert_detail.html`, `sshca_profile_detail.html`
10. Implement `internal/webserver/sshca.go` handlers
11. Register SSH CA routes
### Phase 3: Transit
12. Implement Transit wrapper types and gRPC client methods
13. Add Transit methods to `vaultBackend` interface
14. Create `transit.html`, `transit_key_detail.html`
15. Implement `internal/webserver/transit.go` handlers
16. Register Transit routes
### Phase 4: User
17. Implement User wrapper types and gRPC client methods
18. Add User methods to `vaultBackend` interface
19. Create `user.html`, `user_key_detail.html`
20. Implement `internal/webserver/user.go` handlers
21. Register User routes
### Phase 5: Polish
22. Test all engine UIs end-to-end against a running instance
23. Update `ARCHITECTURE.md` web routes table
24. Update `CLAUDE.md` project structure (if template list changed)
---
## Design Decisions
### No batch operations in UI
Batch encrypt/decrypt/rewrap (transit) are for programmatic use. The web UI
exposes single-operation forms only. Users needing batch operations should use
the REST or gRPC API directly.
### Operation results inline, not separate pages
Encrypt/decrypt/sign/verify results are shown inline on the same page (in a
readonly textarea or success div), not on a separate result page. This
follows the PKI "Sign CSR" pattern where the signed cert appears in the same
card after submission. It avoids navigation complexity and keeps the workflow
tight.
### No JavaScript beyond htmx
All forms use standard POST submission. HTMX is used only where it already
is in the codebase (seal button). No client-side validation, key filtering,
or dynamic form behavior.
### Key selection via `<select>`
Transit and User operations that reference a named key use a `<select>`
dropdown populated server-side. The handler fetches the key list on every
page render. For mounts with many keys, this is acceptable — transit engines
typically have tens of keys, not thousands.
### Profile-gated signing
The SSH CA sign forms require selecting a profile. The profile list is
fetched from the backend — the user can only sign with profiles they have
policy access to. If the gRPC call returns a permission error, the web UI
shows it as a form error, not a 403 page.
### Danger zone pattern
Destructive operations (delete key, delete user, rotate user key) use the
existing pattern: red `.btn-danger` button with `onclick="return
confirm('...')"`. No additional confirmation pages.

View File

@@ -16,8 +16,19 @@ var (
ErrSealed = errors.New("barrier: sealed") ErrSealed = errors.New("barrier: sealed")
ErrNotFound = errors.New("barrier: entry not found") ErrNotFound = errors.New("barrier: entry not found")
ErrKeyNotFound = errors.New("barrier: key not found") ErrKeyNotFound = errors.New("barrier: key not found")
ErrInvalidPath = errors.New("barrier: invalid path")
) )
// validatePath rejects paths containing ".." segments to prevent path traversal.
func validatePath(p string) error {
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return fmt.Errorf("%w: %q", ErrInvalidPath, p)
}
}
return nil
}
// Barrier is the encrypted storage barrier interface. // Barrier is the encrypted storage barrier interface.
type Barrier interface { type Barrier interface {
// Unseal opens the barrier with the given master encryption key. // Unseal opens the barrier with the given master encryption key.
@@ -137,11 +148,12 @@ func resolveKeyID(path string) string {
} }
func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) { func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
if err := validatePath(path); err != nil {
return nil, err
}
b.mu.RLock() b.mu.RLock()
mek := b.mek defer b.mu.RUnlock()
keys := b.keys if b.mek == nil {
b.mu.RUnlock()
if mek == nil {
return nil, ErrSealed return nil, ErrSealed
} }
@@ -161,7 +173,7 @@ func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("barrier: extract key ID %q: %w", path, err) return nil, fmt.Errorf("barrier: extract key ID %q: %w", path, err)
} }
dek, ok := keys[keyID] dek, ok := b.keys[keyID]
if !ok { if !ok {
return nil, fmt.Errorf("barrier: %w: %q for path %q", ErrKeyNotFound, keyID, path) return nil, fmt.Errorf("barrier: %w: %q for path %q", ErrKeyNotFound, keyID, path)
} }
@@ -173,7 +185,7 @@ func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
} }
// v1 ciphertext — use MEK directly (backward compat). // v1 ciphertext — use MEK directly (backward compat).
pt, err := crypto.Decrypt(mek, encrypted, []byte(path)) pt, err := crypto.Decrypt(b.mek, encrypted, []byte(path))
if err != nil { if err != nil {
return nil, fmt.Errorf("barrier: decrypt %q: %w", path, err) return nil, fmt.Errorf("barrier: decrypt %q: %w", path, err)
} }
@@ -181,11 +193,12 @@ func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
} }
func (b *AESGCMBarrier) Put(ctx context.Context, path string, value []byte) error { func (b *AESGCMBarrier) Put(ctx context.Context, path string, value []byte) error {
if err := validatePath(path); err != nil {
return err
}
b.mu.RLock() b.mu.RLock()
mek := b.mek defer b.mu.RUnlock()
keys := b.keys if b.mek == nil {
b.mu.RUnlock()
if mek == nil {
return ErrSealed return ErrSealed
} }
@@ -194,12 +207,12 @@ func (b *AESGCMBarrier) Put(ctx context.Context, path string, value []byte) erro
var encrypted []byte var encrypted []byte
var err error var err error
if dek, ok := keys[keyID]; ok { if dek, ok := b.keys[keyID]; ok {
// Use v2 format with the appropriate DEK. // Use v2 format with the appropriate DEK.
encrypted, err = crypto.EncryptV2(dek, keyID, value, []byte(path)) encrypted, err = crypto.EncryptV2(dek, keyID, value, []byte(path))
} else { } else {
// No DEK registered for this key ID — fall back to MEK with v1 format. // No DEK registered for this key ID — fall back to MEK with v1 format.
encrypted, err = crypto.Encrypt(mek, value, []byte(path)) encrypted, err = crypto.Encrypt(b.mek, value, []byte(path))
} }
if err != nil { if err != nil {
return fmt.Errorf("barrier: encrypt %q: %w", path, err) return fmt.Errorf("barrier: encrypt %q: %w", path, err)
@@ -216,10 +229,12 @@ func (b *AESGCMBarrier) Put(ctx context.Context, path string, value []byte) erro
} }
func (b *AESGCMBarrier) Delete(ctx context.Context, path string) error { func (b *AESGCMBarrier) Delete(ctx context.Context, path string) error {
if err := validatePath(path); err != nil {
return err
}
b.mu.RLock() b.mu.RLock()
mek := b.mek defer b.mu.RUnlock()
b.mu.RUnlock() if b.mek == nil {
if mek == nil {
return ErrSealed return ErrSealed
} }
@@ -232,10 +247,12 @@ func (b *AESGCMBarrier) Delete(ctx context.Context, path string) error {
} }
func (b *AESGCMBarrier) List(ctx context.Context, prefix string) ([]string, error) { func (b *AESGCMBarrier) List(ctx context.Context, prefix string) ([]string, error) {
if err := validatePath(prefix); err != nil {
return nil, err
}
b.mu.RLock() b.mu.RLock()
mek := b.mek defer b.mu.RUnlock()
b.mu.RUnlock() if b.mek == nil {
if mek == nil {
return nil, ErrSealed return nil, ErrSealed
} }
@@ -605,7 +622,8 @@ func (b *AESGCMBarrier) createKeyLockedTx(ctx context.Context, tx *sql.Tx, keyID
} }
// ReWrapKeys re-encrypts all DEKs with a new MEK. Called during MEK rotation. // ReWrapKeys re-encrypts all DEKs with a new MEK. Called during MEK rotation.
// The new MEK is already set in b.mek by the caller. // This method manages its own transaction. For atomic MEK rotation where
// the seal_config update must be in the same transaction, use ReWrapKeysTx.
func (b *AESGCMBarrier) ReWrapKeys(ctx context.Context, newMEK []byte) error { func (b *AESGCMBarrier) ReWrapKeys(ctx context.Context, newMEK []byte) error {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
@@ -620,6 +638,30 @@ func (b *AESGCMBarrier) ReWrapKeys(ctx context.Context, newMEK []byte) error {
} }
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
if err := b.reWrapKeysLocked(ctx, tx, newMEK); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("barrier: commit re-wrap: %w", err)
}
b.swapMEKLocked(newMEK)
return nil
}
// ReWrapKeysTx re-encrypts all DEKs with a new MEK within the given
// transaction. The caller is responsible for committing the transaction
// and then calling SwapMEK to update the in-memory state.
// The barrier mutex must be held by the caller.
func (b *AESGCMBarrier) ReWrapKeysTx(ctx context.Context, tx *sql.Tx, newMEK []byte) error {
if b.mek == nil {
return ErrSealed
}
return b.reWrapKeysLocked(ctx, tx, newMEK)
}
func (b *AESGCMBarrier) reWrapKeysLocked(ctx context.Context, tx *sql.Tx, newMEK []byte) error {
for keyID, dek := range b.keys { for keyID, dek := range b.keys {
encDEK, err := crypto.Encrypt(newMEK, dek, []byte(keyID)) encDEK, err := crypto.Encrypt(newMEK, dek, []byte(keyID))
if err != nil { if err != nil {
@@ -632,15 +674,19 @@ func (b *AESGCMBarrier) ReWrapKeys(ctx context.Context, newMEK []byte) error {
return fmt.Errorf("barrier: update key %q: %w", keyID, err) return fmt.Errorf("barrier: update key %q: %w", keyID, err)
} }
} }
return nil
if err := tx.Commit(); err != nil {
return fmt.Errorf("barrier: commit re-wrap: %w", err)
} }
// Update the MEK in memory. // SwapMEK updates the in-memory MEK after a committed transaction.
func (b *AESGCMBarrier) SwapMEK(newMEK []byte) {
b.mu.Lock()
defer b.mu.Unlock()
b.swapMEKLocked(newMEK)
}
func (b *AESGCMBarrier) swapMEKLocked(newMEK []byte) {
crypto.Zeroize(b.mek) crypto.Zeroize(b.mek)
k := make([]byte, len(newMEK)) k := make([]byte, len(newMEK))
copy(k, newMEK) copy(k, newMEK)
b.mek = k b.mek = k
return nil
} }

View File

@@ -634,6 +634,9 @@ func (e *CAEngine) handleCreateIssuer(ctx context.Context, req *engine.Request)
if name == "" { if name == "" {
return nil, fmt.Errorf("ca: issuer name is required") return nil, fmt.Errorf("ca: issuer name is required")
} }
if err := engine.ValidateName(name); err != nil {
return nil, fmt.Errorf("ca: %w", err)
}
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
@@ -873,10 +876,13 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName) return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName)
} }
// Apply user overrides. // Validate and apply TTL against issuer MaxTTL.
if v, ok := req.Data["ttl"].(string); ok && v != "" { requestedTTL, _ := req.Data["ttl"].(string)
profile.Expiry = v ttl, err := resolveTTL(requestedTTL, is.config.MaxTTL)
if err != nil {
return nil, err
} }
profile.Expiry = ttl.String()
if v, ok := req.Data["key_usages"].([]interface{}); ok { if v, ok := req.Data["key_usages"].([]interface{}); ok {
profile.KeyUse = toStringSlice(v) profile.KeyUse = toStringSlice(v)
} }
@@ -1259,10 +1265,6 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName) return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName)
} }
if v, ok := req.Data["ttl"].(string); ok && v != "" {
profile.Expiry = v
}
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
@@ -1275,6 +1277,14 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
return nil, ErrIssuerNotFound return nil, ErrIssuerNotFound
} }
// Validate and apply TTL against issuer MaxTTL.
requestedTTL, _ := req.Data["ttl"].(string)
ttl, err := resolveTTL(requestedTTL, is.config.MaxTTL)
if err != nil {
return nil, err
}
profile.Expiry = ttl.String()
// Authorization: admins bypass; otherwise check identifiers from the CSR. // Authorization: admins bypass; otherwise check identifiers from the CSR.
if !req.CallerInfo.IsAdmin { if !req.CallerInfo.IsAdmin {
sans := append(csr.DNSNames, ipStrings(csr.IPAddresses)...) sans := append(csr.DNSNames, ipStrings(csr.IPAddresses)...)
@@ -1497,6 +1507,25 @@ func zeroizeKey(key crypto.PrivateKey) {
} }
} }
// resolveTTL parses and validates a requested TTL against the issuer's MaxTTL.
func resolveTTL(requested, issuerMaxTTL string) (time.Duration, error) {
maxTTL, err := time.ParseDuration(issuerMaxTTL)
if err != nil || maxTTL <= 0 {
maxTTL = 2160 * time.Hour // 90 days fallback
}
if requested != "" {
ttl, err := time.ParseDuration(requested)
if err != nil {
return 0, fmt.Errorf("ca: invalid TTL %q: %w", requested, err)
}
if ttl > maxTTL {
return 0, fmt.Errorf("ca: requested TTL %s exceeds issuer maximum %s", ttl, maxTTL)
}
return ttl, nil
}
return maxTTL, nil
}
func toStringSlice(v []interface{}) []string { func toStringSlice(v []interface{}) []string {
s := make([]string, 0, len(v)) s := make([]string, 0, len(v))
for _, item := range v { for _, item := range v {

View File

@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"regexp"
"strings" "strings"
"sync" "sync"
@@ -28,8 +29,21 @@ var (
ErrMountExists = errors.New("engine: mount already exists") ErrMountExists = errors.New("engine: mount already exists")
ErrMountNotFound = errors.New("engine: mount not found") ErrMountNotFound = errors.New("engine: mount not found")
ErrUnknownType = errors.New("engine: unknown engine type") ErrUnknownType = errors.New("engine: unknown engine type")
ErrInvalidName = errors.New("engine: invalid name")
) )
var validName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// ValidateName checks that a user-provided name is safe for use in barrier
// paths. Names must be 1-128 characters, start with an alphanumeric, and
// contain only alphanumerics, dots, hyphens, and underscores.
func ValidateName(name string) error {
if name == "" || len(name) > 128 || !validName.MatchString(name) {
return fmt.Errorf("%w: %q", ErrInvalidName, name)
}
return nil
}
// CallerInfo carries authentication context into engines. // CallerInfo carries authentication context into engines.
type CallerInfo struct { type CallerInfo struct {
Username string Username string
@@ -131,6 +145,10 @@ const mountsPrefix = "engine/_mounts/"
// Mount creates and initializes a new engine mount. // Mount creates and initializes a new engine mount.
func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType, config map[string]interface{}) error { func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType, config map[string]interface{}) error {
if err := ValidateName(name); err != nil {
return err
}
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()

View File

@@ -547,6 +547,9 @@ func (e *SSHCAEngine) handleCreateProfile(ctx context.Context, req *engine.Reque
if name == "" { if name == "" {
return nil, fmt.Errorf("sshca: name is required") return nil, fmt.Errorf("sshca: name is required")
} }
if err := engine.ValidateName(name); err != nil {
return nil, fmt.Errorf("sshca: %w", err)
}
// Check if profile already exists. // Check if profile already exists.
_, err := e.barrier.Get(ctx, e.mountPath+"profiles/"+name+".json") _, err := e.barrier.Get(ctx, e.mountPath+"profiles/"+name+".json")

View File

@@ -382,6 +382,9 @@ func (e *TransitEngine) handleCreateKey(ctx context.Context, req *engine.Request
if name == "" { if name == "" {
return nil, fmt.Errorf("transit: name is required") return nil, fmt.Errorf("transit: name is required")
} }
if err := engine.ValidateName(name); err != nil {
return nil, fmt.Errorf("transit: %w", err)
}
if keyType == "" { if keyType == "" {
keyType = "aes256-gcm" keyType = "aes256-gcm"
} }

View File

@@ -48,6 +48,7 @@ var (
// userState holds in-memory state for a loaded user. // userState holds in-memory state for a loaded user.
type userState struct { type userState struct {
privKey *ecdh.PrivateKey privKey *ecdh.PrivateKey
privBytes []byte // raw private key bytes, retained for zeroization
pubKey *ecdh.PublicKey pubKey *ecdh.PublicKey
config *UserKeyConfig config *UserKeyConfig
} }
@@ -68,6 +69,16 @@ func NewUserEngine() engine.Engine {
} }
} }
// mountName extracts the mount name from the full mount path.
// mountPath is "engine/user/{name}/".
func (e *UserEngine) mountName() string {
parts := strings.Split(strings.TrimSuffix(e.mountPath, "/"), "/")
if len(parts) >= 3 {
return parts[2]
}
return e.mountPath
}
func (e *UserEngine) Type() engine.EngineType { func (e *UserEngine) Type() engine.EngineType {
return engine.EngineTypeUser return engine.EngineTypeUser
} }
@@ -154,12 +165,13 @@ func (e *UserEngine) Seal() error {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
// Zeroize all private keys. // Zeroize all private key material.
for _, u := range e.users { for _, u := range e.users {
if u.privKey != nil { if u.privBytes != nil {
raw := u.privKey.Bytes() crypto.Zeroize(u.privBytes)
crypto.Zeroize(raw) u.privBytes = nil
} }
u.privKey = nil
} }
e.users = nil e.users = nil
e.config = nil e.config = nil
@@ -249,6 +261,9 @@ func (e *UserEngine) handleProvision(ctx context.Context, req *engine.Request) (
if username == "" { if username == "" {
return nil, fmt.Errorf("user: username is required") return nil, fmt.Errorf("user: username is required")
} }
if err := engine.ValidateName(username); err != nil {
return nil, fmt.Errorf("user: %w", err)
}
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
@@ -349,13 +364,18 @@ func (e *UserEngine) handleEncrypt(ctx context.Context, req *engine.Request) (*e
if len(recipientNames) > maxRecipients { if len(recipientNames) > maxRecipients {
return nil, ErrTooMany return nil, ErrTooMany
} }
for _, r := range recipientNames {
if err := engine.ValidateName(r); err != nil {
return nil, fmt.Errorf("user: invalid recipient: %w", err)
}
}
sender := req.CallerInfo.Username sender := req.CallerInfo.Username
// Policy check for each recipient. // Policy check for each recipient.
if req.CheckPolicy != nil { if req.CheckPolicy != nil {
for _, r := range recipientNames { for _, r := range recipientNames {
resource := fmt.Sprintf("user/%s/recipient/%s", e.mountPath, r) resource := fmt.Sprintf("user/%s/recipient/%s", e.mountName(), r)
effect, matched := req.CheckPolicy(resource, "write") effect, matched := req.CheckPolicy(resource, "write")
if matched && effect == "deny" { if matched && effect == "deny" {
return nil, fmt.Errorf("user: forbidden: policy denies encryption to recipient %s", r) return nil, fmt.Errorf("user: forbidden: policy denies encryption to recipient %s", r)
@@ -716,6 +736,7 @@ func (e *UserEngine) createUser(ctx context.Context, username string, autoProvis
u := &userState{ u := &userState{
privKey: priv, privKey: priv,
privBytes: priv.Bytes(), // retain copy for zeroization on Seal
pubKey: priv.PublicKey(), pubKey: priv.PublicKey(),
config: &UserKeyConfig{ config: &UserKeyConfig{
Algorithm: e.config.KeyAlgorithm, Algorithm: e.config.KeyAlgorithm,
@@ -790,6 +811,7 @@ func (e *UserEngine) loadUser(ctx context.Context, username string) error {
e.users[username] = &userState{ e.users[username] = &userState{
privKey: priv, privKey: priv,
privBytes: privBytes, // retained for zeroization on Seal
pubKey: priv.PublicKey(), pubKey: priv.PublicKey(),
config: &cfg, config: &cfg,
} }

View File

@@ -128,6 +128,8 @@ func LintRule(rule *Rule) []string {
if rule.ID == "" { if rule.ID == "" {
problems = append(problems, "rule ID is required") problems = append(problems, "rule ID is required")
} else if strings.Contains(rule.ID, "/") || strings.Contains(rule.ID, "..") {
problems = append(problems, "rule ID must not contain '/' or '..'")
} }
if !validEffects[rule.Effect] { if !validEffects[rule.Effect] {

View File

@@ -284,28 +284,40 @@ func (m *Manager) RotateMEK(ctx context.Context, password []byte) error {
return fmt.Errorf("seal: generate new mek: %w", err) return fmt.Errorf("seal: generate new mek: %w", err)
} }
// Re-wrap all DEKs with new MEK. // Encrypt new MEK with KWK before starting the transaction.
if err := m.barrier.ReWrapKeys(ctx, newMEK); err != nil {
crypto.Zeroize(newMEK)
return fmt.Errorf("seal: re-wrap keys: %w", err)
}
// Encrypt new MEK with KWK.
newEncMEK, err := crypto.Encrypt(kwk, newMEK, nil) newEncMEK, err := crypto.Encrypt(kwk, newMEK, nil)
if err != nil { if err != nil {
crypto.Zeroize(newMEK) crypto.Zeroize(newMEK)
return fmt.Errorf("seal: encrypt new mek: %w", err) return fmt.Errorf("seal: encrypt new mek: %w", err)
} }
// Update seal_config. // Re-wrap DEKs and update seal_config in a single atomic transaction.
_, err = m.db.ExecContext(ctx, tx, err := m.db.BeginTx(ctx, nil)
if err != nil {
crypto.Zeroize(newMEK)
return fmt.Errorf("seal: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := m.barrier.ReWrapKeysTx(ctx, tx, newMEK); err != nil {
crypto.Zeroize(newMEK)
return fmt.Errorf("seal: re-wrap keys: %w", err)
}
_, err = tx.ExecContext(ctx,
"UPDATE seal_config SET encrypted_mek = ? WHERE id = 1", newEncMEK) "UPDATE seal_config SET encrypted_mek = ? WHERE id = 1", newEncMEK)
if err != nil { if err != nil {
crypto.Zeroize(newMEK) crypto.Zeroize(newMEK)
return fmt.Errorf("seal: update seal config: %w", err) return fmt.Errorf("seal: update seal config: %w", err)
} }
// Swap in-memory MEK. if err := tx.Commit(); err != nil {
crypto.Zeroize(newMEK)
return fmt.Errorf("seal: commit mek rotation: %w", err)
}
// Only after commit: swap in-memory state.
m.barrier.SwapMEK(newMEK)
crypto.Zeroize(m.mek) crypto.Zeroize(m.mek)
m.mek = newMEK m.mek = newMEK
m.logger.Info("MEK rotated successfully") m.logger.Info("MEK rotated successfully")

View File

@@ -11,6 +11,7 @@ import (
mcias "git.wntrmute.dev/kyle/mcias/clients/go" mcias "git.wntrmute.dev/kyle/mcias/clients/go"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier" "git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto" "git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/engine" "git.wntrmute.dev/kyle/metacrypt/internal/engine"
@@ -53,10 +54,10 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Get("/v1/sshca/{mount}/profiles/{name}", s.requireAuth(s.handleSSHCAGetProfile)) r.Get("/v1/sshca/{mount}/profiles/{name}", s.requireAuth(s.handleSSHCAGetProfile))
r.Get("/v1/sshca/{mount}/profiles", s.requireAuth(s.handleSSHCAListProfiles)) r.Get("/v1/sshca/{mount}/profiles", s.requireAuth(s.handleSSHCAListProfiles))
r.Delete("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCADeleteProfile)) r.Delete("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCADeleteProfile))
r.Get("/v1/sshca/{mount}/certs/{serial}", s.requireAuth(s.handleSSHCAGetCert)) r.Get("/v1/sshca/{mount}/cert/{serial}", s.requireAuth(s.handleSSHCAGetCert))
r.Get("/v1/sshca/{mount}/certs", s.requireAuth(s.handleSSHCAListCerts)) r.Get("/v1/sshca/{mount}/certs", s.requireAuth(s.handleSSHCAListCerts))
r.Post("/v1/sshca/{mount}/certs/{serial}/revoke", s.requireAdmin(s.handleSSHCARevokeCert)) r.Post("/v1/sshca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleSSHCARevokeCert))
r.Delete("/v1/sshca/{mount}/certs/{serial}", s.requireAdmin(s.handleSSHCADeleteCert)) r.Delete("/v1/sshca/{mount}/cert/{serial}", s.requireAdmin(s.handleSSHCADeleteCert))
// Public PKI routes (no auth required, but must be unsealed). // Public PKI routes (no auth required, but must be unsealed).
r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot)) r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
@@ -89,7 +90,7 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Get("/v1/transit/{mount}/keys/{name}", s.requireAuth(s.handleTransitGetKey)) r.Get("/v1/transit/{mount}/keys/{name}", s.requireAuth(s.handleTransitGetKey))
r.Delete("/v1/transit/{mount}/keys/{name}", s.requireAdmin(s.handleTransitDeleteKey)) r.Delete("/v1/transit/{mount}/keys/{name}", s.requireAdmin(s.handleTransitDeleteKey))
r.Post("/v1/transit/{mount}/keys/{name}/rotate", s.requireAdmin(s.handleTransitRotateKey)) r.Post("/v1/transit/{mount}/keys/{name}/rotate", s.requireAdmin(s.handleTransitRotateKey))
r.Post("/v1/transit/{mount}/keys/{name}/config", s.requireAdmin(s.handleTransitUpdateKeyConfig)) r.Patch("/v1/transit/{mount}/keys/{name}/config", s.requireAdmin(s.handleTransitUpdateKeyConfig))
r.Post("/v1/transit/{mount}/keys/{name}/trim", s.requireAdmin(s.handleTransitTrimKey)) r.Post("/v1/transit/{mount}/keys/{name}/trim", s.requireAdmin(s.handleTransitTrimKey))
r.Post("/v1/transit/{mount}/encrypt/{key}", s.requireAuth(s.handleTransitEncrypt)) r.Post("/v1/transit/{mount}/encrypt/{key}", s.requireAuth(s.handleTransitEncrypt))
r.Post("/v1/transit/{mount}/decrypt/{key}", s.requireAuth(s.handleTransitDecrypt)) r.Post("/v1/transit/{mount}/decrypt/{key}", s.requireAuth(s.handleTransitDecrypt))
@@ -287,7 +288,7 @@ func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
if err := s.engines.Mount(r.Context(), req.Name, engine.EngineType(req.Type), req.Config); err != nil { if err := s.engines.Mount(r.Context(), req.Name, engine.EngineType(req.Type), req.Config); err != nil {
s.logger.Error("mount engine", "name", req.Name, "type", req.Type, "error", err) s.logger.Error("mount engine", "name", req.Name, "type", req.Type, "error", err)
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest) writeJSONError(w, err.Error(), http.StatusBadRequest)
return return
} }
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
@@ -302,7 +303,7 @@ func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.engines.Unmount(r.Context(), req.Name); err != nil { if err := s.engines.Unmount(r.Context(), req.Name); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) writeJSONError(w, err.Error(), http.StatusNotFound)
return return
} }
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
@@ -434,7 +435,7 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
case strings.Contains(err.Error(), "not found"): case strings.Contains(err.Error(), "not found"):
status = http.StatusNotFound status = http.StatusNotFound
} }
http.Error(w, `{"error":"`+err.Error()+`"}`, status) writeJSONError(w, err.Error(), status)
return return
} }
@@ -615,13 +616,14 @@ func (s *Server) handleGetCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
if errors.Is(err, ca.ErrCertNotFound) { if errors.Is(err, ca.ErrCertNotFound) {
http.Error(w, `{"error":"certificate not found"}`, http.StatusNotFound) http.Error(w, `{"error":"certificate not found"}`, http.StatusNotFound)
return return
} }
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError) writeJSONError(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeJSON(w, http.StatusOK, resp.Data) writeJSON(w, http.StatusOK, resp.Data)
@@ -640,6 +642,7 @@ func (s *Server) handleRevokeCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
if errors.Is(err, ca.ErrCertNotFound) { if errors.Is(err, ca.ErrCertNotFound) {
@@ -650,7 +653,7 @@ func (s *Server) handleRevokeCert(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return return
} }
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError) writeJSONError(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeJSON(w, http.StatusOK, resp.Data) writeJSON(w, http.StatusOK, resp.Data)
@@ -669,6 +672,7 @@ func (s *Server) handleDeleteCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
if errors.Is(err, ca.ErrCertNotFound) { if errors.Is(err, ca.ErrCertNotFound) {
@@ -679,7 +683,7 @@ func (s *Server) handleDeleteCert(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return return
} }
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError) writeJSONError(w, err.Error(), http.StatusInternalServerError)
return return
} }
writeJSON(w, http.StatusNoContent, nil) writeJSON(w, http.StatusNoContent, nil)
@@ -691,7 +695,7 @@ func (s *Server) handlePKIRoot(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount") mountName := chi.URLParam(r, "mount")
caEng, err := s.getCAEngine(mountName) caEng, err := s.getCAEngine(mountName)
if err != nil { if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) writeJSONError(w, err.Error(), http.StatusNotFound)
return return
} }
@@ -715,7 +719,7 @@ func (s *Server) handlePKIChain(w http.ResponseWriter, r *http.Request) {
caEng, err := s.getCAEngine(mountName) caEng, err := s.getCAEngine(mountName)
if err != nil { if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) writeJSONError(w, err.Error(), http.StatusNotFound)
return return
} }
@@ -739,7 +743,7 @@ func (s *Server) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
caEng, err := s.getCAEngine(mountName) caEng, err := s.getCAEngine(mountName)
if err != nil { if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) writeJSONError(w, err.Error(), http.StatusNotFound)
return return
} }
@@ -763,7 +767,7 @@ func (s *Server) handlePKICRL(w http.ResponseWriter, r *http.Request) {
caEng, err := s.getCAEngine(mountName) caEng, err := s.getCAEngine(mountName)
if err != nil { if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) writeJSONError(w, err.Error(), http.StatusNotFound)
return return
} }
@@ -1047,6 +1051,7 @@ func (s *Server) handleUserRegister(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1076,6 +1081,7 @@ func (s *Server) handleUserProvision(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"username": req.Username}, Data: map[string]interface{}{"username": req.Username},
}) })
if err != nil { if err != nil {
@@ -1095,6 +1101,7 @@ func (s *Server) handleUserListUsers(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1114,6 +1121,7 @@ func (s *Server) handleUserGetPublicKey(w http.ResponseWriter, r *http.Request)
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"username": username}, Data: map[string]interface{}{"username": username},
}) })
if err != nil { if err != nil {
@@ -1134,6 +1142,7 @@ func (s *Server) handleUserDeleteUser(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"username": username}, Data: map[string]interface{}{"username": username},
}) })
if err != nil { if err != nil {
@@ -1215,6 +1224,7 @@ func (s *Server) handleUserDecrypt(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"envelope": req.Envelope}, Data: map[string]interface{}{"envelope": req.Envelope},
}) })
if err != nil { if err != nil {
@@ -1241,6 +1251,7 @@ func (s *Server) handleUserReEncrypt(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"envelope": req.Envelope}, Data: map[string]interface{}{"envelope": req.Envelope},
}) })
if err != nil { if err != nil {
@@ -1260,6 +1271,7 @@ func (s *Server) handleUserRotateKey(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1301,6 +1313,28 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
_ = json.NewEncoder(w).Encode(v) _ = json.NewEncoder(w).Encode(v)
} }
func writeJSONError(w http.ResponseWriter, msg string, status int) {
writeJSON(w, status, map[string]string{"error": msg})
}
// newPolicyChecker builds a PolicyChecker closure for a caller, used by typed
// REST handlers to pass service-level policy evaluation into the engine.
func (s *Server) newPolicyChecker(r *http.Request, info *auth.TokenInfo) engine.PolicyChecker {
return func(resource, action string) (string, bool) {
pReq := &policy.Request{
Username: info.Username,
Roles: info.Roles,
Resource: resource,
Action: action,
}
eff, matched, pErr := s.policy.Match(r.Context(), pReq)
if pErr != nil {
return string(policy.EffectDeny), false
}
return string(eff), matched
}
}
func readJSON(r *http.Request, v interface{}) error { func readJSON(r *http.Request, v interface{}) error {
defer func() { _ = r.Body.Close() }() defer func() { _ = r.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
@@ -1331,7 +1365,7 @@ func (s *Server) handleSSHCAPubkey(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount") mountName := chi.URLParam(r, "mount")
eng, err := s.getSSHCAEngine(mountName) eng, err := s.getSSHCAEngine(mountName)
if err != nil { if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) writeJSONError(w, err.Error(), http.StatusNotFound)
return return
} }
pubKey, err := eng.GetCAPubkey(r.Context()) pubKey, err := eng.GetCAPubkey(r.Context())
@@ -1347,7 +1381,7 @@ func (s *Server) handleSSHCAKRL(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount") mountName := chi.URLParam(r, "mount")
eng, err := s.getSSHCAEngine(mountName) eng, err := s.getSSHCAEngine(mountName)
if err != nil { if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) writeJSONError(w, err.Error(), http.StatusNotFound)
return return
} }
krlData, err := eng.GetKRL() krlData, err := eng.GetKRL()
@@ -1386,6 +1420,7 @@ func (s *Server) handleSSHCASignHost(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1502,6 +1537,7 @@ func (s *Server) handleSSHCACreateProfile(w http.ResponseWriter, r *http.Request
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1557,6 +1593,7 @@ func (s *Server) handleSSHCAUpdateProfile(w http.ResponseWriter, r *http.Request
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1577,6 +1614,7 @@ func (s *Server) handleSSHCAGetProfile(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1595,6 +1633,7 @@ func (s *Server) handleSSHCAListProfiles(w http.ResponseWriter, r *http.Request)
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1615,6 +1654,7 @@ func (s *Server) handleSSHCADeleteProfile(w http.ResponseWriter, r *http.Request
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1635,6 +1675,7 @@ func (s *Server) handleSSHCAGetCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1653,6 +1694,7 @@ func (s *Server) handleSSHCAListCerts(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1673,6 +1715,7 @@ func (s *Server) handleSSHCARevokeCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1693,6 +1736,7 @@ func (s *Server) handleSSHCADeleteCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles, Roles: info.Roles,
IsAdmin: info.IsAdmin, IsAdmin: info.IsAdmin,
}, },
CheckPolicy: s.newPolicyChecker(r, info),
}) })
if err != nil { if err != nil {
s.writeEngineError(w, err) s.writeEngineError(w, err)
@@ -1728,5 +1772,5 @@ func (s *Server) writeEngineError(w http.ResponseWriter, err error) {
strings.Contains(err.Error(), "too many"): strings.Contains(err.Error(), "too many"):
status = http.StatusBadRequest status = http.StatusBadRequest
} }
http.Error(w, `{"error":"`+err.Error()+`"}`, status) writeJSONError(w, err.Error(), status)
} }

View File

@@ -124,6 +124,120 @@ func (m *mockVault) CreatePolicy(ctx context.Context, token string, rule PolicyR
func (m *mockVault) DeletePolicy(ctx context.Context, token, id string) error { return nil } func (m *mockVault) DeletePolicy(ctx context.Context, token, id string) error { return nil }
// SSH CA stubs
func (m *mockVault) GetSSHCAPublicKey(ctx context.Context, mount string) (*SSHCAPublicKey, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) SSHCASignHost(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) SSHCASignUser(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) ListSSHCAProfiles(ctx context.Context, token, mount string) ([]SSHCAProfileSummary, error) {
return nil, nil
}
func (m *mockVault) GetSSHCAProfile(ctx context.Context, token, mount, name string) (*SSHCAProfile, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) CreateSSHCAProfile(ctx context.Context, token, mount string, req SSHCAProfileRequest) error {
return nil
}
func (m *mockVault) UpdateSSHCAProfile(ctx context.Context, token, mount, name string, req SSHCAProfileRequest) error {
return nil
}
func (m *mockVault) DeleteSSHCAProfile(ctx context.Context, token, mount, name string) error {
return nil
}
func (m *mockVault) ListSSHCACerts(ctx context.Context, token, mount string) ([]SSHCACertSummary, error) {
return nil, nil
}
func (m *mockVault) GetSSHCACert(ctx context.Context, token, mount, serial string) (*SSHCACertDetail, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) RevokeSSHCACert(ctx context.Context, token, mount, serial string) error {
return nil
}
func (m *mockVault) DeleteSSHCACert(ctx context.Context, token, mount, serial string) error {
return nil
}
func (m *mockVault) GetSSHCAKRL(ctx context.Context, mount string) ([]byte, error) {
return nil, fmt.Errorf("not implemented")
}
// Transit stubs
func (m *mockVault) ListTransitKeys(ctx context.Context, token, mount string) ([]TransitKeySummary, error) {
return nil, nil
}
func (m *mockVault) GetTransitKey(ctx context.Context, token, mount, name string) (*TransitKeyDetail, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) CreateTransitKey(ctx context.Context, token, mount, name, keyType string) error {
return nil
}
func (m *mockVault) DeleteTransitKey(ctx context.Context, token, mount, name string) error {
return nil
}
func (m *mockVault) RotateTransitKey(ctx context.Context, token, mount, name string) error {
return nil
}
func (m *mockVault) UpdateTransitKeyConfig(ctx context.Context, token, mount, name string, minDecryptVersion int, allowDeletion bool) error {
return nil
}
func (m *mockVault) TrimTransitKey(ctx context.Context, token, mount, name string) (int, error) {
return 0, nil
}
func (m *mockVault) TransitEncrypt(ctx context.Context, token, mount, key, plaintext, transitCtx string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) TransitDecrypt(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) TransitRewrap(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) TransitSign(ctx context.Context, token, mount, key, input string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) TransitVerify(ctx context.Context, token, mount, key, input, signature string) (bool, error) {
return false, fmt.Errorf("not implemented")
}
func (m *mockVault) TransitHMAC(ctx context.Context, token, mount, key, input string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) GetTransitPublicKey(ctx context.Context, token, mount, name string) (string, error) {
return "", fmt.Errorf("not implemented")
}
// User stubs
func (m *mockVault) UserRegister(ctx context.Context, token, mount string) (*UserKeyInfo, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) UserProvision(ctx context.Context, token, mount, username string) (*UserKeyInfo, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) GetUserPublicKey(ctx context.Context, token, mount, username string) (*UserKeyInfo, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) ListUsers(ctx context.Context, token, mount string) ([]string, error) {
return nil, nil
}
func (m *mockVault) UserEncrypt(ctx context.Context, token, mount, plaintext, metadata string, recipients []string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) UserDecrypt(ctx context.Context, token, mount, envelope string) (*UserDecryptResult, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) UserReEncrypt(ctx context.Context, token, mount, envelope string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) UserRotateKey(ctx context.Context, token, mount string) (*UserKeyInfo, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) UserDeleteUser(ctx context.Context, token, mount, username string) error {
return nil
}
func (m *mockVault) Close() error { return nil } func (m *mockVault) Close() error { return nil }
// ---- handleTGZDownload tests ---- // ---- handleTGZDownload tests ----

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"strings"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
@@ -24,6 +25,9 @@ type VaultClient struct {
pki pb.PKIServiceClient pki pb.PKIServiceClient
ca pb.CAServiceClient ca pb.CAServiceClient
policy pb.PolicyServiceClient policy pb.PolicyServiceClient
sshca pb.SSHCAServiceClient
transit pb.TransitServiceClient
user pb.UserServiceClient
} }
// NewVaultClient dials the vault gRPC server and returns a client. // NewVaultClient dials the vault gRPC server and returns a client.
@@ -62,6 +66,9 @@ func NewVaultClient(addr, caCertPath string, logger *slog.Logger) (*VaultClient,
pki: pb.NewPKIServiceClient(conn), pki: pb.NewPKIServiceClient(conn),
ca: pb.NewCAServiceClient(conn), ca: pb.NewCAServiceClient(conn),
policy: pb.NewPolicyServiceClient(conn), policy: pb.NewPolicyServiceClient(conn),
sshca: pb.NewSSHCAServiceClient(conn),
transit: pb.NewTransitServiceClient(conn),
user: pb.NewUserServiceClient(conn),
}, nil }, nil
} }
@@ -496,3 +503,610 @@ func (c *VaultClient) ListCerts(ctx context.Context, token, mount string) ([]Cer
} }
return certs, nil return certs, nil
} }
// ---------------------------------------------------------------------------
// SSH CA
// ---------------------------------------------------------------------------
// SSHCAPublicKey holds the CA public key details for display.
type SSHCAPublicKey struct {
PublicKey string // authorized_keys format
}
// GetSSHCAPublicKey returns the SSH CA public key for a mount.
func (c *VaultClient) GetSSHCAPublicKey(ctx context.Context, mount string) (*SSHCAPublicKey, error) {
resp, err := c.sshca.GetCAPublicKey(ctx, &pb.SSHGetCAPublicKeyRequest{Mount: mount})
if err != nil {
return nil, err
}
return &SSHCAPublicKey{PublicKey: resp.PublicKey}, nil
}
// SSHCASignRequest holds parameters for signing an SSH certificate.
type SSHCASignRequest struct {
PublicKey string
Principals []string
Profile string
TTL string
}
// SSHCASignResult holds the result of signing an SSH certificate.
type SSHCASignResult struct {
Serial string
CertType string
Principals []string
CertData string
KeyID string
IssuedBy string
IssuedAt string
ExpiresAt string
}
func sshSignResultFromHost(resp *pb.SSHSignHostResponse) *SSHCASignResult {
r := &SSHCASignResult{
Serial: resp.Serial,
CertType: resp.CertType,
Principals: resp.Principals,
CertData: resp.CertData,
KeyID: resp.KeyId,
IssuedBy: resp.IssuedBy,
}
if resp.IssuedAt != nil {
r.IssuedAt = resp.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if resp.ExpiresAt != nil {
r.ExpiresAt = resp.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return r
}
func sshSignResultFromUser(resp *pb.SSHSignUserResponse) *SSHCASignResult {
r := &SSHCASignResult{
Serial: resp.Serial,
CertType: resp.CertType,
Principals: resp.Principals,
CertData: resp.CertData,
KeyID: resp.KeyId,
IssuedBy: resp.IssuedBy,
}
if resp.IssuedAt != nil {
r.IssuedAt = resp.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if resp.ExpiresAt != nil {
r.ExpiresAt = resp.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return r
}
// SSHCASignHost signs an SSH host certificate.
func (c *VaultClient) SSHCASignHost(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error) {
hostname := ""
if len(req.Principals) > 0 {
hostname = req.Principals[0]
}
resp, err := c.sshca.SignHost(withToken(ctx, token), &pb.SSHSignHostRequest{
Mount: mount,
PublicKey: req.PublicKey,
Hostname: hostname,
Ttl: req.TTL,
})
if err != nil {
return nil, err
}
return sshSignResultFromHost(resp), nil
}
// SSHCASignUser signs an SSH user certificate.
func (c *VaultClient) SSHCASignUser(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error) {
resp, err := c.sshca.SignUser(withToken(ctx, token), &pb.SSHSignUserRequest{
Mount: mount,
PublicKey: req.PublicKey,
Principals: req.Principals,
Profile: req.Profile,
Ttl: req.TTL,
})
if err != nil {
return nil, err
}
return sshSignResultFromUser(resp), nil
}
// SSHCAProfileSummary holds lightweight profile data for list views.
type SSHCAProfileSummary struct {
Name string
}
// ListSSHCAProfiles returns all signing profile names for a mount.
func (c *VaultClient) ListSSHCAProfiles(ctx context.Context, token, mount string) ([]SSHCAProfileSummary, error) {
resp, err := c.sshca.ListProfiles(withToken(ctx, token), &pb.SSHListProfilesRequest{Mount: mount})
if err != nil {
return nil, err
}
profiles := make([]SSHCAProfileSummary, 0, len(resp.Profiles))
for _, name := range resp.Profiles {
profiles = append(profiles, SSHCAProfileSummary{Name: name})
}
return profiles, nil
}
// SSHCAProfile holds full profile data for the detail view.
type SSHCAProfile struct {
Name string
CriticalOptions map[string]string
Extensions map[string]string
MaxTTL string
AllowedPrincipals []string
}
// GetSSHCAProfile retrieves a signing profile by name.
func (c *VaultClient) GetSSHCAProfile(ctx context.Context, token, mount, name string) (*SSHCAProfile, error) {
resp, err := c.sshca.GetProfile(withToken(ctx, token), &pb.SSHGetProfileRequest{Mount: mount, Name: name})
if err != nil {
return nil, err
}
return &SSHCAProfile{
Name: resp.Name,
CriticalOptions: resp.CriticalOptions,
Extensions: resp.Extensions,
MaxTTL: resp.MaxTtl,
AllowedPrincipals: resp.AllowedPrincipals,
}, nil
}
// SSHCAProfileRequest holds parameters for creating or updating a profile.
type SSHCAProfileRequest struct {
Name string
CriticalOptions map[string]string
Extensions map[string]string
MaxTTL string
AllowedPrincipals []string
}
// CreateSSHCAProfile creates a new signing profile.
func (c *VaultClient) CreateSSHCAProfile(ctx context.Context, token, mount string, req SSHCAProfileRequest) error {
_, err := c.sshca.CreateProfile(withToken(ctx, token), &pb.SSHCreateProfileRequest{
Mount: mount,
Name: req.Name,
CriticalOptions: req.CriticalOptions,
Extensions: req.Extensions,
MaxTtl: req.MaxTTL,
AllowedPrincipals: req.AllowedPrincipals,
})
return err
}
// UpdateSSHCAProfile updates an existing signing profile.
func (c *VaultClient) UpdateSSHCAProfile(ctx context.Context, token, mount, name string, req SSHCAProfileRequest) error {
_, err := c.sshca.UpdateProfile(withToken(ctx, token), &pb.SSHUpdateProfileRequest{
Mount: mount,
Name: name,
CriticalOptions: req.CriticalOptions,
Extensions: req.Extensions,
MaxTtl: req.MaxTTL,
AllowedPrincipals: req.AllowedPrincipals,
})
return err
}
// DeleteSSHCAProfile removes a signing profile.
func (c *VaultClient) DeleteSSHCAProfile(ctx context.Context, token, mount, name string) error {
_, err := c.sshca.DeleteProfile(withToken(ctx, token), &pb.SSHDeleteProfileRequest{Mount: mount, Name: name})
return err
}
// SSHCACertSummary holds lightweight cert data for list views.
type SSHCACertSummary struct {
Serial string
CertType string
Principals string
KeyID string
Profile string
IssuedBy string
IssuedAt string
ExpiresAt string
Revoked bool
}
// ListSSHCACerts returns all SSH certificate summaries for a mount.
func (c *VaultClient) ListSSHCACerts(ctx context.Context, token, mount string) ([]SSHCACertSummary, error) {
resp, err := c.sshca.ListCerts(withToken(ctx, token), &pb.SSHListCertsRequest{Mount: mount})
if err != nil {
return nil, err
}
certs := make([]SSHCACertSummary, 0, len(resp.Certs))
for _, s := range resp.Certs {
cs := SSHCACertSummary{
Serial: s.Serial,
CertType: s.CertType,
Principals: strings.Join(s.Principals, ", "),
KeyID: s.KeyId,
Profile: s.Profile,
IssuedBy: s.IssuedBy,
Revoked: s.Revoked,
}
if s.IssuedAt != nil {
cs.IssuedAt = s.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if s.ExpiresAt != nil {
cs.ExpiresAt = s.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
certs = append(certs, cs)
}
return certs, nil
}
// SSHCACertDetail holds full SSH certificate data for the detail view.
type SSHCACertDetail struct {
Serial string
CertType string
Principals []string
CertData string
KeyID string
Profile string
IssuedBy string
IssuedAt string
ExpiresAt string
Revoked bool
RevokedAt string
RevokedBy string
}
// GetSSHCACert retrieves a full SSH certificate record by serial.
func (c *VaultClient) GetSSHCACert(ctx context.Context, token, mount, serial string) (*SSHCACertDetail, error) {
resp, err := c.sshca.GetCert(withToken(ctx, token), &pb.SSHGetCertRequest{Mount: mount, Serial: serial})
if err != nil {
return nil, err
}
rec := resp.GetCert()
if rec == nil {
return nil, fmt.Errorf("cert not found")
}
cd := &SSHCACertDetail{
Serial: rec.Serial,
CertType: rec.CertType,
Principals: rec.Principals,
CertData: rec.CertData,
KeyID: rec.KeyId,
Profile: rec.Profile,
IssuedBy: rec.IssuedBy,
Revoked: rec.Revoked,
RevokedBy: rec.RevokedBy,
}
if rec.IssuedAt != nil {
cd.IssuedAt = rec.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if rec.ExpiresAt != nil {
cd.ExpiresAt = rec.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if rec.RevokedAt != nil {
cd.RevokedAt = rec.RevokedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return cd, nil
}
// RevokeSSHCACert marks an SSH certificate as revoked.
func (c *VaultClient) RevokeSSHCACert(ctx context.Context, token, mount, serial string) error {
_, err := c.sshca.RevokeCert(withToken(ctx, token), &pb.SSHRevokeCertRequest{Mount: mount, Serial: serial})
return err
}
// DeleteSSHCACert permanently removes an SSH certificate record.
func (c *VaultClient) DeleteSSHCACert(ctx context.Context, token, mount, serial string) error {
_, err := c.sshca.DeleteCert(withToken(ctx, token), &pb.SSHDeleteCertRequest{Mount: mount, Serial: serial})
return err
}
// GetSSHCAKRL returns the binary KRL data for a mount.
func (c *VaultClient) GetSSHCAKRL(ctx context.Context, mount string) ([]byte, error) {
resp, err := c.sshca.GetKRL(ctx, &pb.SSHGetKRLRequest{Mount: mount})
if err != nil {
return nil, err
}
return resp.Krl, nil
}
// ---------------------------------------------------------------------------
// Transit
// ---------------------------------------------------------------------------
// TransitKeySummary holds lightweight key data for list views.
type TransitKeySummary struct {
Name string
}
// ListTransitKeys returns all key names for a mount.
func (c *VaultClient) ListTransitKeys(ctx context.Context, token, mount string) ([]TransitKeySummary, error) {
resp, err := c.transit.ListKeys(withToken(ctx, token), &pb.ListTransitKeysRequest{Mount: mount})
if err != nil {
return nil, err
}
keys := make([]TransitKeySummary, 0, len(resp.Keys))
for _, name := range resp.Keys {
keys = append(keys, TransitKeySummary{Name: name})
}
return keys, nil
}
// TransitKeyDetail holds full key metadata for the detail view.
type TransitKeyDetail struct {
Name string
Type string
CurrentVersion int
MinDecryptionVersion int
AllowDeletion bool
Versions []int
}
// GetTransitKey retrieves key metadata.
func (c *VaultClient) GetTransitKey(ctx context.Context, token, mount, name string) (*TransitKeyDetail, error) {
resp, err := c.transit.GetKey(withToken(ctx, token), &pb.GetTransitKeyRequest{Mount: mount, Name: name})
if err != nil {
return nil, err
}
versions := make([]int, 0, len(resp.Versions))
for _, v := range resp.Versions {
versions = append(versions, int(v))
}
return &TransitKeyDetail{
Name: resp.Name,
Type: resp.Type,
CurrentVersion: int(resp.CurrentVersion),
MinDecryptionVersion: int(resp.MinDecryptionVersion),
AllowDeletion: resp.AllowDeletion,
Versions: versions,
}, nil
}
// CreateTransitKey creates a new named key.
func (c *VaultClient) CreateTransitKey(ctx context.Context, token, mount, name, keyType string) error {
_, err := c.transit.CreateKey(withToken(ctx, token), &pb.CreateTransitKeyRequest{
Mount: mount,
Name: name,
Type: keyType,
})
return err
}
// DeleteTransitKey permanently removes a named key.
func (c *VaultClient) DeleteTransitKey(ctx context.Context, token, mount, name string) error {
_, err := c.transit.DeleteKey(withToken(ctx, token), &pb.DeleteTransitKeyRequest{Mount: mount, Name: name})
return err
}
// RotateTransitKey creates a new version of the named key.
func (c *VaultClient) RotateTransitKey(ctx context.Context, token, mount, name string) error {
_, err := c.transit.RotateKey(withToken(ctx, token), &pb.RotateTransitKeyRequest{Mount: mount, Name: name})
return err
}
// UpdateTransitKeyConfig updates key config (min_decryption_version, allow_deletion).
func (c *VaultClient) UpdateTransitKeyConfig(ctx context.Context, token, mount, name string, minDecryptVersion int, allowDeletion bool) error {
_, err := c.transit.UpdateKeyConfig(withToken(ctx, token), &pb.UpdateTransitKeyConfigRequest{
Mount: mount,
Name: name,
MinDecryptionVersion: int32(minDecryptVersion),
AllowDeletion: allowDeletion,
})
return err
}
// TrimTransitKey deletes old key versions below min_decryption_version.
func (c *VaultClient) TrimTransitKey(ctx context.Context, token, mount, name string) (int, error) {
resp, err := c.transit.TrimKey(withToken(ctx, token), &pb.TrimTransitKeyRequest{Mount: mount, Name: name})
if err != nil {
return 0, err
}
return int(resp.Trimmed), nil
}
// TransitEncrypt encrypts plaintext with a named key.
func (c *VaultClient) TransitEncrypt(ctx context.Context, token, mount, key, plaintext, transitCtx string) (string, error) {
resp, err := c.transit.Encrypt(withToken(ctx, token), &pb.TransitEncryptRequest{
Mount: mount,
Key: key,
Plaintext: plaintext,
Context: transitCtx,
})
if err != nil {
return "", err
}
return resp.Ciphertext, nil
}
// TransitDecrypt decrypts ciphertext with a named key.
func (c *VaultClient) TransitDecrypt(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error) {
resp, err := c.transit.Decrypt(withToken(ctx, token), &pb.TransitDecryptRequest{
Mount: mount,
Key: key,
Ciphertext: ciphertext,
Context: transitCtx,
})
if err != nil {
return "", err
}
return resp.Plaintext, nil
}
// TransitRewrap re-encrypts ciphertext with the latest key version.
func (c *VaultClient) TransitRewrap(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error) {
resp, err := c.transit.Rewrap(withToken(ctx, token), &pb.TransitRewrapRequest{
Mount: mount,
Key: key,
Ciphertext: ciphertext,
Context: transitCtx,
})
if err != nil {
return "", err
}
return resp.Ciphertext, nil
}
// TransitSign signs input data with an asymmetric key.
func (c *VaultClient) TransitSign(ctx context.Context, token, mount, key, input string) (string, error) {
resp, err := c.transit.Sign(withToken(ctx, token), &pb.TransitSignRequest{
Mount: mount,
Key: key,
Input: input,
})
if err != nil {
return "", err
}
return resp.Signature, nil
}
// TransitVerify verifies a signature against input data.
func (c *VaultClient) TransitVerify(ctx context.Context, token, mount, key, input, signature string) (bool, error) {
resp, err := c.transit.Verify(withToken(ctx, token), &pb.TransitVerifyRequest{
Mount: mount,
Key: key,
Input: input,
Signature: signature,
})
if err != nil {
return false, err
}
return resp.Valid, nil
}
// TransitHMAC computes an HMAC.
func (c *VaultClient) TransitHMAC(ctx context.Context, token, mount, key, input string) (string, error) {
resp, err := c.transit.Hmac(withToken(ctx, token), &pb.TransitHmacRequest{
Mount: mount,
Key: key,
Input: input,
})
if err != nil {
return "", err
}
return resp.Hmac, nil
}
// GetTransitPublicKey returns the public key for an asymmetric transit key.
func (c *VaultClient) GetTransitPublicKey(ctx context.Context, token, mount, name string) (string, error) {
resp, err := c.transit.GetPublicKey(withToken(ctx, token), &pb.GetTransitPublicKeyRequest{Mount: mount, Name: name})
if err != nil {
return "", err
}
return resp.PublicKey, nil
}
// ---------------------------------------------------------------------------
// User (E2E Encryption)
// ---------------------------------------------------------------------------
// UserKeyInfo holds a user's public key details.
type UserKeyInfo struct {
Username string
PublicKey string
Algorithm string
}
// UserRegister self-registers the caller.
func (c *VaultClient) UserRegister(ctx context.Context, token, mount string) (*UserKeyInfo, error) {
resp, err := c.user.Register(withToken(ctx, token), &pb.UserRegisterRequest{Mount: mount})
if err != nil {
return nil, err
}
return &UserKeyInfo{
Username: resp.Username,
PublicKey: resp.PublicKey,
Algorithm: resp.Algorithm,
}, nil
}
// UserProvision creates a keypair for a given username. Admin only.
func (c *VaultClient) UserProvision(ctx context.Context, token, mount, username string) (*UserKeyInfo, error) {
resp, err := c.user.Provision(withToken(ctx, token), &pb.UserProvisionRequest{Mount: mount, Username: username})
if err != nil {
return nil, err
}
return &UserKeyInfo{
Username: resp.Username,
PublicKey: resp.PublicKey,
Algorithm: resp.Algorithm,
}, nil
}
// GetUserPublicKey returns the public key for a username.
func (c *VaultClient) GetUserPublicKey(ctx context.Context, token, mount, username string) (*UserKeyInfo, error) {
resp, err := c.user.GetPublicKey(withToken(ctx, token), &pb.UserGetPublicKeyRequest{Mount: mount, Username: username})
if err != nil {
return nil, err
}
return &UserKeyInfo{
Username: resp.Username,
PublicKey: resp.PublicKey,
Algorithm: resp.Algorithm,
}, nil
}
// ListUsers returns all registered usernames.
func (c *VaultClient) ListUsers(ctx context.Context, token, mount string) ([]string, error) {
resp, err := c.user.ListUsers(withToken(ctx, token), &pb.UserListUsersRequest{Mount: mount})
if err != nil {
return nil, err
}
return resp.Users, nil
}
// UserEncrypt encrypts plaintext for one or more recipients.
func (c *VaultClient) UserEncrypt(ctx context.Context, token, mount, plaintext, userMetadata string, recipients []string) (string, error) {
resp, err := c.user.Encrypt(withToken(ctx, token), &pb.UserEncryptRequest{
Mount: mount,
Plaintext: plaintext,
Metadata: userMetadata,
Recipients: recipients,
})
if err != nil {
return "", err
}
return resp.Envelope, nil
}
// UserDecryptResult holds the result of decrypting an envelope.
type UserDecryptResult struct {
Plaintext string
Sender string
Metadata string
}
// UserDecrypt decrypts an envelope addressed to the caller.
func (c *VaultClient) UserDecrypt(ctx context.Context, token, mount, envelope string) (*UserDecryptResult, error) {
resp, err := c.user.Decrypt(withToken(ctx, token), &pb.UserDecryptRequest{Mount: mount, Envelope: envelope})
if err != nil {
return nil, err
}
return &UserDecryptResult{
Plaintext: resp.Plaintext,
Sender: resp.Sender,
Metadata: resp.Metadata,
}, nil
}
// UserReEncrypt re-encrypts an envelope with current keys.
func (c *VaultClient) UserReEncrypt(ctx context.Context, token, mount, envelope string) (string, error) {
resp, err := c.user.ReEncrypt(withToken(ctx, token), &pb.UserReEncryptRequest{Mount: mount, Envelope: envelope})
if err != nil {
return "", err
}
return resp.Envelope, nil
}
// UserRotateKey generates a new keypair for the caller.
func (c *VaultClient) UserRotateKey(ctx context.Context, token, mount string) (*UserKeyInfo, error) {
resp, err := c.user.RotateKey(withToken(ctx, token), &pb.UserRotateKeyRequest{Mount: mount})
if err != nil {
return nil, err
}
return &UserKeyInfo{
Username: resp.Username,
PublicKey: resp.PublicKey,
Algorithm: resp.Algorithm,
}, nil
}
// UserDeleteUser removes a user's keys. Admin only.
func (c *VaultClient) UserDeleteUser(ctx context.Context, token, mount, username string) error {
_, err := c.user.DeleteUser(withToken(ctx, token), &pb.UserDeleteUserRequest{Mount: mount, Username: username})
return err
}

View File

@@ -38,6 +38,7 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.HandleFunc("/login", ws.handleLogin) r.HandleFunc("/login", ws.handleLogin)
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard)) r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA)) r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA))
r.Post("/dashboard/mount-engine", ws.requireAuth(ws.handleDashboardMountEngine))
r.Route("/policy", func(r chi.Router) { r.Route("/policy", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handlePolicy)) r.Get("/", ws.requireAuth(ws.handlePolicy))
@@ -45,6 +46,47 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.Post("/delete", ws.requireAuth(ws.handlePolicyDelete)) r.Post("/delete", ws.requireAuth(ws.handlePolicyDelete))
}) })
r.Route("/sshca", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleSSHCA))
r.Post("/sign-user", ws.requireAuth(ws.handleSSHCASignUser))
r.Post("/sign-host", ws.requireAuth(ws.handleSSHCASignHost))
r.Get("/cert/{serial}", ws.requireAuth(ws.handleSSHCACertDetail))
r.Post("/cert/{serial}/revoke", ws.requireAuth(ws.handleSSHCACertRevoke))
r.Post("/cert/{serial}/delete", ws.requireAuth(ws.handleSSHCACertDelete))
r.Post("/profile/create", ws.requireAuth(ws.handleSSHCACreateProfile))
r.Get("/profile/{name}", ws.requireAuth(ws.handleSSHCAProfileDetail))
r.Post("/profile/{name}/update", ws.requireAuth(ws.handleSSHCAUpdateProfile))
r.Post("/profile/{name}/delete", ws.requireAuth(ws.handleSSHCADeleteProfile))
})
r.Route("/transit", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleTransit))
r.Get("/key/{name}", ws.requireAuth(ws.handleTransitKeyDetail))
r.Post("/key/create", ws.requireAuth(ws.handleTransitCreateKey))
r.Post("/key/{name}/rotate", ws.requireAuth(ws.handleTransitRotateKey))
r.Post("/key/{name}/config", ws.requireAuth(ws.handleTransitUpdateConfig))
r.Post("/key/{name}/trim", ws.requireAuth(ws.handleTransitTrimKey))
r.Post("/key/{name}/delete", ws.requireAuth(ws.handleTransitDeleteKey))
r.Post("/encrypt", ws.requireAuth(ws.handleTransitEncrypt))
r.Post("/decrypt", ws.requireAuth(ws.handleTransitDecrypt))
r.Post("/rewrap", ws.requireAuth(ws.handleTransitRewrap))
r.Post("/sign", ws.requireAuth(ws.handleTransitSign))
r.Post("/verify", ws.requireAuth(ws.handleTransitVerify))
r.Post("/hmac", ws.requireAuth(ws.handleTransitHMAC))
})
r.Route("/user", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleUser))
r.Post("/register", ws.requireAuth(ws.handleUserRegister))
r.Post("/provision", ws.requireAuth(ws.handleUserProvision))
r.Get("/key/{username}", ws.requireAuth(ws.handleUserKeyDetail))
r.Post("/encrypt", ws.requireAuth(ws.handleUserEncrypt))
r.Post("/decrypt", ws.requireAuth(ws.handleUserDecrypt))
r.Post("/re-encrypt", ws.requireAuth(ws.handleUserReEncrypt))
r.Post("/rotate", ws.requireAuth(ws.handleUserRotateKey))
r.Post("/delete/{username}", ws.requireAuth(ws.handleUserDeleteUser))
})
r.Route("/pki", func(r chi.Router) { r.Route("/pki", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handlePKI)) r.Get("/", ws.requireAuth(ws.handlePKI))
r.Post("/import-root", ws.requireAuth(ws.handleImportRoot)) r.Post("/import-root", ws.requireAuth(ws.handleImportRoot))
@@ -201,13 +243,11 @@ func (ws *WebServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
token := extractCookie(r) token := extractCookie(r)
mounts, _ := ws.vault.ListMounts(r.Context(), token) mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context()) state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{ data := ws.baseData(r, info)
"Username": info.Username, data["Roles"] = info.Roles
"IsAdmin": info.IsAdmin, data["Mounts"] = mounts
"Roles": info.Roles, data["State"] = state
"Mounts": mounts, ws.renderTemplate(w, "dashboard.html", data)
"State": state,
})
} }
func (ws *WebServer) handleDashboardMountCA(w http.ResponseWriter, r *http.Request) { func (ws *WebServer) handleDashboardMountCA(w http.ResponseWriter, r *http.Request) {
@@ -258,18 +298,57 @@ func (ws *WebServer) handleDashboardMountCA(w http.ResponseWriter, r *http.Reque
http.Redirect(w, r, "/pki", http.StatusFound) http.Redirect(w, r, "/pki", http.StatusFound)
} }
func (ws *WebServer) handleDashboardMountEngine(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
mountName := r.FormValue("name")
engineType := r.FormValue("type")
if mountName == "" || engineType == "" {
ws.renderDashboardWithError(w, r, info, "Mount name and engine type are required")
return
}
cfg := map[string]interface{}{}
if v := r.FormValue("key_algorithm"); v != "" {
cfg["key_algorithm"] = v
}
token := extractCookie(r)
if err := ws.vault.Mount(r.Context(), token, mountName, engineType, cfg); err != nil {
ws.renderDashboardWithError(w, r, info, grpcMessage(err))
return
}
// Redirect to the appropriate engine page.
switch engineType {
case "sshca":
http.Redirect(w, r, "/sshca", http.StatusFound)
case "transit":
http.Redirect(w, r, "/transit", http.StatusFound)
case "user":
http.Redirect(w, r, "/user", http.StatusFound)
default:
http.Redirect(w, r, "/dashboard", http.StatusFound)
}
}
func (ws *WebServer) renderDashboardWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, errMsg string) { func (ws *WebServer) renderDashboardWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, errMsg string) {
token := extractCookie(r) token := extractCookie(r)
mounts, _ := ws.vault.ListMounts(r.Context(), token) mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context()) state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{ data := ws.baseData(r, info)
"Username": info.Username, data["Roles"] = info.Roles
"IsAdmin": info.IsAdmin, data["Mounts"] = mounts
"Roles": info.Roles, data["State"] = state
"Mounts": mounts, data["MountError"] = errMsg
"State": state, ws.renderTemplate(w, "dashboard.html", data)
"MountError": errMsg,
})
} }
func (ws *WebServer) handlePKI(w http.ResponseWriter, r *http.Request) { func (ws *WebServer) handlePKI(w http.ResponseWriter, r *http.Request) {
@@ -282,11 +361,8 @@ func (ws *WebServer) handlePKI(w http.ResponseWriter, r *http.Request) {
return return
} }
data := map[string]interface{}{ data := ws.baseData(r, info)
"Username": info.Username, data["MountName"] = mountName
"IsAdmin": info.IsAdmin,
"MountName": mountName,
}
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 { if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil { if cert, err := parsePEMCert(rootPEM); err == nil {
@@ -482,15 +558,12 @@ func (ws *WebServer) handleIssuerDetail(w http.ResponseWriter, r *http.Request)
certs[i].IssuedBy = ws.resolveUser(certs[i].IssuedBy) certs[i].IssuedBy = ws.resolveUser(certs[i].IssuedBy)
} }
data := map[string]interface{}{ data := ws.baseData(r, info)
"Username": info.Username, data["MountName"] = mountName
"IsAdmin": info.IsAdmin, data["IssuerName"] = issuerName
"MountName": mountName, data["Certs"] = certs
"IssuerName": issuerName, data["NameFilter"] = r.URL.Query().Get("name")
"Certs": certs, data["SortBy"] = sortBy
"NameFilter": r.URL.Query().Get("name"),
"SortBy": sortBy,
}
ws.renderTemplate(w, "issuer_detail.html", data) ws.renderTemplate(w, "issuer_detail.html", data)
} }
@@ -640,12 +713,10 @@ func (ws *WebServer) handleCertDetail(w http.ResponseWriter, r *http.Request) {
cert.IssuedBy = ws.resolveUser(cert.IssuedBy) cert.IssuedBy = ws.resolveUser(cert.IssuedBy)
cert.RevokedBy = ws.resolveUser(cert.RevokedBy) cert.RevokedBy = ws.resolveUser(cert.RevokedBy)
ws.renderTemplate(w, "cert_detail.html", map[string]interface{}{ data := ws.baseData(r, info)
"Username": info.Username, data["MountName"] = mountName
"IsAdmin": info.IsAdmin, data["Cert"] = cert
"MountName": mountName, ws.renderTemplate(w, "cert_detail.html", data)
"Cert": cert,
})
} }
func (ws *WebServer) handleCertDownload(w http.ResponseWriter, r *http.Request) { func (ws *WebServer) handleCertDownload(w http.ResponseWriter, r *http.Request) {
@@ -776,12 +847,9 @@ func (ws *WebServer) handleSignCSR(w http.ResponseWriter, r *http.Request) {
return return
} }
data := map[string]interface{}{ data := ws.baseData(r, info)
"Username": info.Username, data["MountName"] = mountName
"IsAdmin": info.IsAdmin, data["SignedCert"] = signed
"MountName": mountName,
"SignedCert": signed,
}
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 { if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil { if cert, err := parsePEMCert(rootPEM); err == nil {
data["RootCN"] = cert.Subject.CommonName data["RootCN"] = cert.Subject.CommonName
@@ -800,12 +868,9 @@ func (ws *WebServer) handleSignCSR(w http.ResponseWriter, r *http.Request) {
func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) { func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r) token := extractCookie(r)
data := map[string]interface{}{ data := ws.baseData(r, info)
"Username": info.Username, data["MountName"] = mountName
"IsAdmin": info.IsAdmin, data["Error"] = errMsg
"MountName": mountName,
"Error": errMsg,
}
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 { if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil { if cert, err := parsePEMCert(rootPEM); err == nil {
@@ -825,16 +890,45 @@ func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request,
} }
func (ws *WebServer) findCAMount(r *http.Request, token string) (string, error) { func (ws *WebServer) findCAMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "ca")
}
func (ws *WebServer) findSSHCAMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "sshca")
}
func (ws *WebServer) findTransitMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "transit")
}
func (ws *WebServer) findUserMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "user")
}
func (ws *WebServer) findMount(r *http.Request, token, engineType string) (string, error) {
mounts, err := ws.vault.ListMounts(r.Context(), token) mounts, err := ws.vault.ListMounts(r.Context(), token)
if err != nil { if err != nil {
return "", err return "", err
} }
for _, m := range mounts { for _, m := range mounts {
if m.Type == "ca" { if m.Type == engineType {
return m.Name, nil return m.Name, nil
} }
} }
return "", fmt.Errorf("no CA engine mounted") return "", fmt.Errorf("no %s engine mounted", engineType)
}
// mountTypes returns a set of engine types that are currently mounted.
func (ws *WebServer) mountTypes(r *http.Request, token string) map[string]bool {
mounts, err := ws.vault.ListMounts(r.Context(), token)
if err != nil {
return nil
}
types := make(map[string]bool, len(mounts))
for _, m := range mounts {
types[m.Type] = true
}
return types
} }
func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) { func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
@@ -848,11 +942,9 @@ func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
rules = []PolicyRule{} rules = []PolicyRule{}
} }
ws.renderTemplate(w, "policy.html", map[string]interface{}{ data := ws.baseData(r, info)
"Username": info.Username, data["Rules"] = rules
"IsAdmin": info.IsAdmin, ws.renderTemplate(w, "policy.html", data)
"Rules": rules,
})
} }
func (ws *WebServer) handlePolicyCreate(w http.ResponseWriter, r *http.Request) { func (ws *WebServer) handlePolicyCreate(w http.ResponseWriter, r *http.Request) {
@@ -927,12 +1019,23 @@ func (ws *WebServer) handlePolicyDelete(w http.ResponseWriter, r *http.Request)
func (ws *WebServer) renderPolicyWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, token, errMsg string) { func (ws *WebServer) renderPolicyWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, token, errMsg string) {
rules, _ := ws.vault.ListPolicies(r.Context(), token) rules, _ := ws.vault.ListPolicies(r.Context(), token)
ws.renderTemplate(w, "policy.html", map[string]interface{}{ data := ws.baseData(r, info)
data["Rules"] = rules
data["Error"] = errMsg
ws.renderTemplate(w, "policy.html", data)
}
// baseData returns a template data map pre-populated with user info and nav flags.
func (ws *WebServer) baseData(r *http.Request, info *TokenInfo) map[string]interface{} {
token := extractCookie(r)
types := ws.mountTypes(r, token)
return map[string]interface{}{
"Username": info.Username, "Username": info.Username,
"IsAdmin": info.IsAdmin, "IsAdmin": info.IsAdmin,
"Rules": rules, "HasSSHCA": types["sshca"],
"Error": errMsg, "HasTransit": types["transit"],
}) "HasUser": types["user"],
}
} }
// grpcMessage extracts a human-readable message from a gRPC error. // grpcMessage extracts a human-readable message from a gRPC error.

View File

@@ -23,6 +23,7 @@ import (
// vaultBackend is the interface used by WebServer to communicate with the vault. // vaultBackend is the interface used by WebServer to communicate with the vault.
// It is satisfied by *VaultClient and can be replaced with a mock in tests. // It is satisfied by *VaultClient and can be replaced with a mock in tests.
type vaultBackend interface { type vaultBackend interface {
// System
Status(ctx context.Context) (string, error) Status(ctx context.Context) (string, error)
Init(ctx context.Context, password string) error Init(ctx context.Context, password string) error
Unseal(ctx context.Context, password string) error Unseal(ctx context.Context, password string) error
@@ -30,6 +31,9 @@ type vaultBackend interface {
ValidateToken(ctx context.Context, token string) (*TokenInfo, error) ValidateToken(ctx context.Context, token string) (*TokenInfo, error)
ListMounts(ctx context.Context, token string) ([]MountInfo, error) ListMounts(ctx context.Context, token string) ([]MountInfo, error)
Mount(ctx context.Context, token, name, engineType string, config map[string]interface{}) error Mount(ctx context.Context, token, name, engineType string, config map[string]interface{}) error
Close() error
// PKI / CA
GetRootCert(ctx context.Context, mount string) ([]byte, error) GetRootCert(ctx context.Context, mount string) ([]byte, error)
GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, error) GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, error)
ImportRoot(ctx context.Context, token, mount, certPEM, keyPEM string) error ImportRoot(ctx context.Context, token, mount, certPEM, keyPEM string) error
@@ -41,11 +45,54 @@ type vaultBackend interface {
ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error) ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error)
RevokeCert(ctx context.Context, token, mount, serial string) error RevokeCert(ctx context.Context, token, mount, serial string) error
DeleteCert(ctx context.Context, token, mount, serial string) error DeleteCert(ctx context.Context, token, mount, serial string) error
// Policy
ListPolicies(ctx context.Context, token string) ([]PolicyRule, error) ListPolicies(ctx context.Context, token string) ([]PolicyRule, error)
GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error) GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error)
CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, error) CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, error)
DeletePolicy(ctx context.Context, token, id string) error DeletePolicy(ctx context.Context, token, id string) error
Close() error
// SSH CA
GetSSHCAPublicKey(ctx context.Context, mount string) (*SSHCAPublicKey, error)
SSHCASignHost(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error)
SSHCASignUser(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error)
ListSSHCAProfiles(ctx context.Context, token, mount string) ([]SSHCAProfileSummary, error)
GetSSHCAProfile(ctx context.Context, token, mount, name string) (*SSHCAProfile, error)
CreateSSHCAProfile(ctx context.Context, token, mount string, req SSHCAProfileRequest) error
UpdateSSHCAProfile(ctx context.Context, token, mount, name string, req SSHCAProfileRequest) error
DeleteSSHCAProfile(ctx context.Context, token, mount, name string) error
ListSSHCACerts(ctx context.Context, token, mount string) ([]SSHCACertSummary, error)
GetSSHCACert(ctx context.Context, token, mount, serial string) (*SSHCACertDetail, error)
RevokeSSHCACert(ctx context.Context, token, mount, serial string) error
DeleteSSHCACert(ctx context.Context, token, mount, serial string) error
GetSSHCAKRL(ctx context.Context, mount string) ([]byte, error)
// Transit
ListTransitKeys(ctx context.Context, token, mount string) ([]TransitKeySummary, error)
GetTransitKey(ctx context.Context, token, mount, name string) (*TransitKeyDetail, error)
CreateTransitKey(ctx context.Context, token, mount, name, keyType string) error
DeleteTransitKey(ctx context.Context, token, mount, name string) error
RotateTransitKey(ctx context.Context, token, mount, name string) error
UpdateTransitKeyConfig(ctx context.Context, token, mount, name string, minDecryptVersion int, allowDeletion bool) error
TrimTransitKey(ctx context.Context, token, mount, name string) (int, error)
TransitEncrypt(ctx context.Context, token, mount, key, plaintext, transitCtx string) (string, error)
TransitDecrypt(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error)
TransitRewrap(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error)
TransitSign(ctx context.Context, token, mount, key, input string) (string, error)
TransitVerify(ctx context.Context, token, mount, key, input, signature string) (bool, error)
TransitHMAC(ctx context.Context, token, mount, key, input string) (string, error)
GetTransitPublicKey(ctx context.Context, token, mount, name string) (string, error)
// User (E2E encryption)
UserRegister(ctx context.Context, token, mount string) (*UserKeyInfo, error)
UserProvision(ctx context.Context, token, mount, username string) (*UserKeyInfo, error)
GetUserPublicKey(ctx context.Context, token, mount, username string) (*UserKeyInfo, error)
ListUsers(ctx context.Context, token, mount string) ([]string, error)
UserEncrypt(ctx context.Context, token, mount, plaintext, metadata string, recipients []string) (string, error)
UserDecrypt(ctx context.Context, token, mount, envelope string) (*UserDecryptResult, error)
UserReEncrypt(ctx context.Context, token, mount, envelope string) (string, error)
UserRotateKey(ctx context.Context, token, mount string) (*UserKeyInfo, error)
UserDeleteUser(ctx context.Context, token, mount, username string) error
} }
const userCacheTTL = 5 * time.Minute const userCacheTTL = 5 * time.Minute

400
internal/webserver/sshca.go Normal file
View File

@@ -0,0 +1,400 @@
package webserver
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (ws *WebServer) handleSSHCA(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
if pubkey, err := ws.vault.GetSSHCAPublicKey(r.Context(), mountName); err == nil {
data["CAPublicKey"] = pubkey.PublicKey
}
if profiles, err := ws.vault.ListSSHCAProfiles(r.Context(), token, mountName); err == nil {
data["Profiles"] = profiles
}
if certs, err := ws.vault.ListSSHCACerts(r.Context(), token, mountName); err == nil {
for i := range certs {
certs[i].IssuedBy = ws.resolveUser(certs[i].IssuedBy)
}
data["Certs"] = certs
}
ws.renderTemplate(w, "sshca.html", data)
}
func (ws *WebServer) handleSSHCASignUser(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
publicKey := strings.TrimSpace(r.FormValue("public_key"))
if publicKey == "" {
ws.renderSSHCAWithError(w, r, mountName, info, "Public key is required")
return
}
var principals []string
for _, line := range strings.Split(r.FormValue("principals"), "\n") {
if v := strings.TrimSpace(line); v != "" {
principals = append(principals, v)
}
}
req := SSHCASignRequest{
PublicKey: publicKey,
Principals: principals,
Profile: r.FormValue("profile"),
TTL: r.FormValue("ttl"),
}
result, err := ws.vault.SSHCASignUser(r.Context(), token, mountName, req)
if err != nil {
ws.renderSSHCAWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderSSHCAWithResult(w, r, mountName, info, "SignUserResult", result)
}
func (ws *WebServer) handleSSHCASignHost(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
publicKey := strings.TrimSpace(r.FormValue("public_key"))
if publicKey == "" {
ws.renderSSHCAWithError(w, r, mountName, info, "Public key is required")
return
}
hostname := strings.TrimSpace(r.FormValue("hostname"))
if hostname == "" {
ws.renderSSHCAWithError(w, r, mountName, info, "Hostname is required")
return
}
req := SSHCASignRequest{
PublicKey: publicKey,
Principals: []string{hostname},
TTL: r.FormValue("ttl"),
}
result, err := ws.vault.SSHCASignHost(r.Context(), token, mountName, req)
if err != nil {
ws.renderSSHCAWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderSSHCAWithResult(w, r, mountName, info, "SignHostResult", result)
}
func (ws *WebServer) handleSSHCACertDetail(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
serial := chi.URLParam(r, "serial")
cert, err := ws.vault.GetSSHCACert(r.Context(), token, mountName, serial)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "certificate not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
cert.IssuedBy = ws.resolveUser(cert.IssuedBy)
cert.RevokedBy = ws.resolveUser(cert.RevokedBy)
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Cert"] = cert
ws.renderTemplate(w, "sshca_cert_detail.html", data)
}
func (ws *WebServer) handleSSHCACertRevoke(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
serial := chi.URLParam(r, "serial")
if err := ws.vault.RevokeSSHCACert(r.Context(), token, mountName, serial); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/sshca/cert/"+serial, http.StatusSeeOther)
}
func (ws *WebServer) handleSSHCACertDelete(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
serial := chi.URLParam(r, "serial")
if err := ws.vault.DeleteSSHCACert(r.Context(), token, mountName, serial); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/sshca", http.StatusSeeOther)
}
func (ws *WebServer) handleSSHCACreateProfile(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
ws.renderSSHCAWithError(w, r, mountName, info, "Profile name is required")
return
}
var principals []string
for _, line := range strings.Split(r.FormValue("allowed_principals"), "\n") {
if v := strings.TrimSpace(line); v != "" {
principals = append(principals, v)
}
}
extensions := map[string]string{}
for _, ext := range r.Form["extensions"] {
extensions[ext] = ""
}
criticalOptions := map[string]string{}
if v := strings.TrimSpace(r.FormValue("force_command")); v != "" {
criticalOptions["force-command"] = v
}
if v := strings.TrimSpace(r.FormValue("source_address")); v != "" {
criticalOptions["source-address"] = v
}
req := SSHCAProfileRequest{
Name: name,
CriticalOptions: criticalOptions,
Extensions: extensions,
MaxTTL: r.FormValue("max_ttl"),
AllowedPrincipals: principals,
}
if err := ws.vault.CreateSSHCAProfile(r.Context(), token, mountName, req); err != nil {
ws.renderSSHCAWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/sshca/profile/"+name, http.StatusFound)
}
func (ws *WebServer) handleSSHCAProfileDetail(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
profile, err := ws.vault.GetSSHCAProfile(r.Context(), token, mountName, name)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "profile not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Profile"] = profile
ws.renderTemplate(w, "sshca_profile_detail.html", data)
}
func (ws *WebServer) handleSSHCAUpdateProfile(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
var principals []string
for _, line := range strings.Split(r.FormValue("allowed_principals"), "\n") {
if v := strings.TrimSpace(line); v != "" {
principals = append(principals, v)
}
}
extensions := map[string]string{}
for _, ext := range r.Form["extensions"] {
extensions[ext] = ""
}
criticalOptions := map[string]string{}
if v := strings.TrimSpace(r.FormValue("force_command")); v != "" {
criticalOptions["force-command"] = v
}
if v := strings.TrimSpace(r.FormValue("source_address")); v != "" {
criticalOptions["source-address"] = v
}
req := SSHCAProfileRequest{
CriticalOptions: criticalOptions,
Extensions: extensions,
MaxTTL: r.FormValue("max_ttl"),
AllowedPrincipals: principals,
}
if err := ws.vault.UpdateSSHCAProfile(r.Context(), token, mountName, name, req); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/sshca/profile/"+name, http.StatusSeeOther)
}
func (ws *WebServer) handleSSHCADeleteProfile(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
if err := ws.vault.DeleteSSHCAProfile(r.Context(), token, mountName, name); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/sshca", http.StatusSeeOther)
}
func (ws *WebServer) renderSSHCAWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Error"] = errMsg
if pubkey, err := ws.vault.GetSSHCAPublicKey(r.Context(), mountName); err == nil {
data["CAPublicKey"] = pubkey.PublicKey
}
if profiles, err := ws.vault.ListSSHCAProfiles(r.Context(), token, mountName); err == nil {
data["Profiles"] = profiles
}
if certs, err := ws.vault.ListSSHCACerts(r.Context(), token, mountName); err == nil {
data["Certs"] = certs
}
ws.renderTemplate(w, "sshca.html", data)
}
func (ws *WebServer) renderSSHCAWithResult(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, resultKey string, result *SSHCASignResult) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data[resultKey] = result
if pubkey, err := ws.vault.GetSSHCAPublicKey(r.Context(), mountName); err == nil {
data["CAPublicKey"] = pubkey.PublicKey
}
if profiles, err := ws.vault.ListSSHCAProfiles(r.Context(), token, mountName); err == nil {
data["Profiles"] = profiles
}
if certs, err := ws.vault.ListSSHCACerts(r.Context(), token, mountName); err == nil {
data["Certs"] = certs
}
ws.renderTemplate(w, "sshca.html", data)
}

View File

@@ -0,0 +1,417 @@
package webserver
import (
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (ws *WebServer) handleTransit(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
if keys, err := ws.vault.ListTransitKeys(r.Context(), token, mountName); err == nil {
data["Keys"] = keys
}
ws.renderTemplate(w, "transit.html", data)
}
func (ws *WebServer) handleTransitKeyDetail(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
key, err := ws.vault.GetTransitKey(r.Context(), token, mountName, name)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "key not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Key"] = key
// Fetch public key for asymmetric signing keys.
switch key.Type {
case "ed25519", "ecdsa-p256", "ecdsa-p384":
if pubkey, err := ws.vault.GetTransitPublicKey(r.Context(), token, mountName, name); err == nil {
data["PublicKey"] = pubkey
}
}
ws.renderTemplate(w, "transit_key_detail.html", data)
}
func (ws *WebServer) handleTransitCreateKey(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key name is required")
return
}
keyType := r.FormValue("type")
if keyType == "" {
keyType = "aes256-gcm"
}
if err := ws.vault.CreateTransitKey(r.Context(), token, mountName, name, keyType); err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/transit/key/"+name, http.StatusFound)
}
func (ws *WebServer) handleTransitRotateKey(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
if err := ws.vault.RotateTransitKey(r.Context(), token, mountName, name); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/transit/key/"+name, http.StatusSeeOther)
}
func (ws *WebServer) handleTransitUpdateConfig(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
minDecrypt := 0
if v := r.FormValue("min_decryption_version"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
minDecrypt = n
}
}
allowDeletion := r.FormValue("allow_deletion") == "on"
if err := ws.vault.UpdateTransitKeyConfig(r.Context(), token, mountName, name, minDecrypt, allowDeletion); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/transit/key/"+name, http.StatusSeeOther)
}
func (ws *WebServer) handleTransitTrimKey(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
if _, err := ws.vault.TrimTransitKey(r.Context(), token, mountName, name); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/transit/key/"+name, http.StatusSeeOther)
}
func (ws *WebServer) handleTransitDeleteKey(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
if err := ws.vault.DeleteTransitKey(r.Context(), token, mountName, name); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/transit", http.StatusSeeOther)
}
func (ws *WebServer) handleTransitEncrypt(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
plaintext := r.FormValue("plaintext")
transitCtx := r.FormValue("context")
if key == "" || plaintext == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key and plaintext are required")
return
}
ciphertext, err := ws.vault.TransitEncrypt(r.Context(), token, mountName, key, plaintext, transitCtx)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "EncryptResult", ciphertext)
}
func (ws *WebServer) handleTransitDecrypt(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
ciphertext := r.FormValue("ciphertext")
transitCtx := r.FormValue("context")
if key == "" || ciphertext == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key and ciphertext are required")
return
}
plaintext, err := ws.vault.TransitDecrypt(r.Context(), token, mountName, key, ciphertext, transitCtx)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "DecryptResult", plaintext)
}
func (ws *WebServer) handleTransitRewrap(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
ciphertext := r.FormValue("ciphertext")
transitCtx := r.FormValue("context")
if key == "" || ciphertext == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key and ciphertext are required")
return
}
newCiphertext, err := ws.vault.TransitRewrap(r.Context(), token, mountName, key, ciphertext, transitCtx)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "RewrapResult", newCiphertext)
}
func (ws *WebServer) handleTransitSign(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
input := r.FormValue("input")
if key == "" || input == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key and input are required")
return
}
signature, err := ws.vault.TransitSign(r.Context(), token, mountName, key, input)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "SignResult", signature)
}
func (ws *WebServer) handleTransitVerify(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
input := r.FormValue("input")
signature := r.FormValue("signature")
if key == "" || input == "" || signature == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key, input, and signature are required")
return
}
valid, err := ws.vault.TransitVerify(r.Context(), token, mountName, key, input, signature)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "VerifyResult", valid)
}
func (ws *WebServer) handleTransitHMAC(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
input := r.FormValue("input")
if key == "" || input == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key and input are required")
return
}
hmac, err := ws.vault.TransitHMAC(r.Context(), token, mountName, key, input)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "HMACResult", hmac)
}
func (ws *WebServer) renderTransitWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Error"] = errMsg
if keys, err := ws.vault.ListTransitKeys(r.Context(), token, mountName); err == nil {
data["Keys"] = keys
}
ws.renderTemplate(w, "transit.html", data)
}
func (ws *WebServer) renderTransitWithResult(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, resultKey string, result interface{}) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data[resultKey] = result
if keys, err := ws.vault.ListTransitKeys(r.Context(), token, mountName); err == nil {
data["Keys"] = keys
}
ws.renderTemplate(w, "transit.html", data)
}

277
internal/webserver/user.go Normal file
View File

@@ -0,0 +1,277 @@
package webserver
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (ws *WebServer) handleUser(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
// Try to fetch the caller's own key.
if keyInfo, err := ws.vault.GetUserPublicKey(r.Context(), token, mountName, info.Username); err == nil {
data["OwnKey"] = keyInfo
}
if users, err := ws.vault.ListUsers(r.Context(), token, mountName); err == nil {
data["Users"] = users
}
ws.renderTemplate(w, "user.html", data)
}
func (ws *WebServer) handleUserRegister(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
if _, err := ws.vault.UserRegister(r.Context(), token, mountName); err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/user", http.StatusFound)
}
func (ws *WebServer) handleUserProvision(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
username := strings.TrimSpace(r.FormValue("username"))
if username == "" {
ws.renderUserWithError(w, r, mountName, info, "Username is required")
return
}
if _, err := ws.vault.UserProvision(r.Context(), token, mountName, username); err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/user", http.StatusFound)
}
func (ws *WebServer) handleUserKeyDetail(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
username := chi.URLParam(r, "username")
keyInfo, err := ws.vault.GetUserPublicKey(r.Context(), token, mountName, username)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "user not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["KeyInfo"] = keyInfo
ws.renderTemplate(w, "user_key_detail.html", data)
}
func (ws *WebServer) handleUserEncrypt(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
var recipients []string
for _, line := range strings.Split(r.FormValue("recipients"), "\n") {
if v := strings.TrimSpace(line); v != "" {
recipients = append(recipients, v)
}
}
plaintext := r.FormValue("plaintext")
metadata := r.FormValue("metadata")
if len(recipients) == 0 || plaintext == "" {
ws.renderUserWithError(w, r, mountName, info, "Recipients and plaintext are required")
return
}
envelope, err := ws.vault.UserEncrypt(r.Context(), token, mountName, plaintext, metadata, recipients)
if err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderUserWithResult(w, r, mountName, info, "EncryptResult", envelope)
}
func (ws *WebServer) handleUserDecrypt(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
envelope := r.FormValue("envelope")
if envelope == "" {
ws.renderUserWithError(w, r, mountName, info, "Envelope is required")
return
}
result, err := ws.vault.UserDecrypt(r.Context(), token, mountName, envelope)
if err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderUserWithResult(w, r, mountName, info, "DecryptResult", result)
}
func (ws *WebServer) handleUserReEncrypt(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
envelope := r.FormValue("envelope")
if envelope == "" {
ws.renderUserWithError(w, r, mountName, info, "Envelope is required")
return
}
newEnvelope, err := ws.vault.UserReEncrypt(r.Context(), token, mountName, envelope)
if err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderUserWithResult(w, r, mountName, info, "ReEncryptResult", newEnvelope)
}
func (ws *WebServer) handleUserRotateKey(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
if _, err := ws.vault.UserRotateKey(r.Context(), token, mountName); err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/user", http.StatusSeeOther)
}
func (ws *WebServer) handleUserDeleteUser(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
username := chi.URLParam(r, "username")
if err := ws.vault.UserDeleteUser(r.Context(), token, mountName, username); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/user", http.StatusSeeOther)
}
func (ws *WebServer) renderUserWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Error"] = errMsg
if keyInfo, err := ws.vault.GetUserPublicKey(r.Context(), token, mountName, info.Username); err == nil {
data["OwnKey"] = keyInfo
}
if users, err := ws.vault.ListUsers(r.Context(), token, mountName); err == nil {
data["Users"] = users
}
ws.renderTemplate(w, "user.html", data)
}
func (ws *WebServer) renderUserWithResult(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, resultKey string, result interface{}) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data[resultKey] = result
if keyInfo, err := ws.vault.GetUserPublicKey(r.Context(), token, mountName, info.Username); err == nil {
data["OwnKey"] = keyInfo
}
if users, err := ws.vault.ListUsers(r.Context(), token, mountName); err == nil {
data["Users"] = users
}
ws.renderTemplate(w, "user.html", data)
}

View File

@@ -25,6 +25,12 @@
<td> <td>
{{if eq (printf "%s" .Type) "ca"}} {{if eq (printf "%s" .Type) "ca"}}
<a href="/pki">{{.Name}}</a> <a href="/pki">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "sshca"}}
<a href="/sshca">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "transit"}}
<a href="/transit">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "user"}}
<a href="/user">{{.Name}}</a>
{{else}} {{else}}
{{.Name}} {{.Name}}
{{end}} {{end}}
@@ -75,6 +81,68 @@
</div> </div>
</form> </form>
</details> </details>
<details>
<summary>Mount an SSH CA engine</summary>
<form method="post" action="/dashboard/mount-engine">
{{csrfField}}
<input type="hidden" name="type" value="sshca">
<div class="form-row">
<div class="form-group">
<label for="sshca_name">Mount Name</label>
<input type="text" id="sshca_name" name="name" placeholder="sshca" required>
</div>
<div class="form-group">
<label for="sshca_algo">Key Algorithm</label>
<select id="sshca_algo" name="key_algorithm">
<option value="ed25519">ed25519 (default)</option>
<option value="ecdsa-p256">ecdsa-p256</option>
<option value="ecdsa-p384">ecdsa-p384</option>
</select>
</div>
</div>
<div class="form-actions">
<button type="submit">Mount</button>
</div>
</form>
</details>
<details>
<summary>Mount a Transit engine</summary>
<form method="post" action="/dashboard/mount-engine">
{{csrfField}}
<input type="hidden" name="type" value="transit">
<div class="form-group">
<label for="transit_name">Mount Name</label>
<input type="text" id="transit_name" name="name" placeholder="transit" required>
</div>
<div class="form-actions">
<button type="submit">Mount</button>
</div>
</form>
</details>
<details>
<summary>Mount a User Crypto engine</summary>
<form method="post" action="/dashboard/mount-engine">
{{csrfField}}
<input type="hidden" name="type" value="user">
<div class="form-row">
<div class="form-group">
<label for="user_name">Mount Name</label>
<input type="text" id="user_name" name="name" placeholder="user" required>
</div>
<div class="form-group">
<label for="user_algo">Key Algorithm</label>
<select id="user_algo" name="key_algorithm">
<option value="x25519">x25519 (default)</option>
<option value="ecdh-p256">ecdh-p256</option>
<option value="ecdh-p384">ecdh-p384</option>
</select>
</div>
</div>
<div class="form-actions">
<button type="submit">Mount</button>
</div>
</form>
</details>
</div> </div>
<div class="card"> <div class="card">

View File

@@ -14,6 +14,9 @@
{{if .Username}} {{if .Username}}
<a href="/dashboard" class="btn btn-ghost btn-sm">Dashboard</a> <a href="/dashboard" class="btn btn-ghost btn-sm">Dashboard</a>
<a href="/pki" class="btn btn-ghost btn-sm">PKI</a> <a href="/pki" class="btn btn-ghost btn-sm">PKI</a>
{{if .HasSSHCA}}<a href="/sshca" class="btn btn-ghost btn-sm">SSH CA</a>{{end}}
{{if .HasTransit}}<a href="/transit" class="btn btn-ghost btn-sm">Transit</a>{{end}}
{{if .HasUser}}<a href="/user" class="btn btn-ghost btn-sm">User Crypto</a>{{end}}
{{if .IsAdmin}}<a href="/policy" class="btn btn-ghost btn-sm">Policy</a>{{end}} {{if .IsAdmin}}<a href="/policy" class="btn btn-ghost btn-sm">Policy</a>{{end}}
<span class="topnav-user">{{.Username}}</span> <span class="topnav-user">{{.Username}}</span>
{{if .IsAdmin}}<span class="badge">admin</span>{{end}} {{if .IsAdmin}}<span class="badge">admin</span>{{end}}

216
web/templates/sshca.html Normal file
View File

@@ -0,0 +1,216 @@
{{define "title"}} - SSH CA: {{.MountName}}{{end}}
{{define "content"}}
<div class="page-header">
<h2>SSH CA: {{.MountName}}</h2>
<div class="page-meta">
<a href="/dashboard">&larr; Dashboard</a>
</div>
</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<div class="card">
<div class="card-title">CA Public Key</div>
{{if .CAPublicKey}}
<textarea rows="3" class="pem-input" readonly>{{.CAPublicKey}}</textarea>
<p style="margin-top: 0.5rem; margin-bottom: 0;">
<a href="/v1/sshca/{{.MountName}}/ca" download="ca.pub">Download CA Public Key</a>
</p>
{{else}}
<p>CA public key not available.</p>
{{end}}
</div>
<div class="card">
<div class="card-title">Sign User Certificate</div>
{{if .SignUserResult}}
<div class="success">
<p>User certificate signed. Serial: <code>{{.SignUserResult.Serial}}</code> &mdash; Expires: {{.SignUserResult.ExpiresAt}}</p>
<div class="form-group">
<label>Certificate</label>
<textarea rows="6" class="pem-input" readonly>{{.SignUserResult.CertData}}</textarea>
</div>
</div>
{{else}}
<form method="post" action="/sshca/sign-user">
{{csrfField}}
<div class="form-row">
<div class="form-group">
<label for="sign_user_profile">Profile</label>
<select id="sign_user_profile" name="profile">
<option value="">(default)</option>
{{range .Profiles}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="form-group">
<label for="sign_user_ttl">TTL <small style="text-transform:none;letter-spacing:0;">(optional)</small></label>
<input type="text" id="sign_user_ttl" name="ttl" placeholder="24h">
</div>
</div>
<div class="form-group">
<label for="sign_user_key">Public Key <small style="text-transform:none;letter-spacing:0;">(authorized_keys format)</small></label>
<textarea id="sign_user_key" name="public_key" rows="3" class="pem-input" placeholder="ssh-ed25519 AAAA..." required></textarea>
</div>
<div class="form-group">
<label for="sign_user_principals">Principals <small style="text-transform:none;letter-spacing:0;">(one per line, blank = your username)</small></label>
<textarea id="sign_user_principals" name="principals" rows="2" placeholder="kyle"></textarea>
</div>
<div class="form-actions">
<button type="submit">Sign User Certificate</button>
</div>
</form>
{{end}}
</div>
<div class="card">
<div class="card-title">Sign Host Certificate</div>
{{if .SignHostResult}}
<div class="success">
<p>Host certificate signed. Serial: <code>{{.SignHostResult.Serial}}</code> &mdash; Expires: {{.SignHostResult.ExpiresAt}}</p>
<div class="form-group">
<label>Certificate</label>
<textarea rows="6" class="pem-input" readonly>{{.SignHostResult.CertData}}</textarea>
</div>
</div>
{{else}}
<form method="post" action="/sshca/sign-host">
{{csrfField}}
<div class="form-row">
<div class="form-group">
<label for="sign_host_hostname">Hostname</label>
<input type="text" id="sign_host_hostname" name="hostname" placeholder="server.example.com" required>
</div>
<div class="form-group">
<label for="sign_host_ttl">TTL <small style="text-transform:none;letter-spacing:0;">(optional)</small></label>
<input type="text" id="sign_host_ttl" name="ttl" placeholder="720h">
</div>
</div>
<div class="form-group">
<label for="sign_host_key">Public Key <small style="text-transform:none;letter-spacing:0;">(authorized_keys format)</small></label>
<textarea id="sign_host_key" name="public_key" rows="3" class="pem-input" placeholder="ssh-ed25519 AAAA..." required></textarea>
</div>
<div class="form-actions">
<button type="submit">Sign Host Certificate</button>
</div>
</form>
{{end}}
</div>
<div class="card">
<div class="card-title">Signing Profiles</div>
{{if .Profiles}}
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Profiles}}
<tr>
<td><a href="/sshca/profile/{{.Name}}">{{.Name}}</a></td>
<td><a href="/sshca/profile/{{.Name}}">View</a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p>No signing profiles configured.</p>
{{end}}
{{if .IsAdmin}}
<details style="margin-top: 1rem;">
<summary>Create Profile</summary>
<form method="post" action="/sshca/profile/create">
{{csrfField}}
<div class="form-row">
<div class="form-group">
<label for="profile_name">Name</label>
<input type="text" id="profile_name" name="name" placeholder="engineering" required>
</div>
<div class="form-group">
<label for="profile_max_ttl">Max TTL</label>
<input type="text" id="profile_max_ttl" name="max_ttl" placeholder="8760h">
</div>
</div>
<div class="form-group">
<label for="profile_principals">Allowed Principals <small style="text-transform:none;letter-spacing:0;">(one per line, blank = any)</small></label>
<textarea id="profile_principals" name="allowed_principals" rows="2" placeholder="*"></textarea>
</div>
<div class="form-group">
<label>Extensions</label>
<div class="checkbox-group">
<label class="checkbox-label"><input type="checkbox" name="extensions" value="permit-pty" checked> permit-pty</label>
<label class="checkbox-label"><input type="checkbox" name="extensions" value="permit-agent-forwarding"> permit-agent-forwarding</label>
<label class="checkbox-label"><input type="checkbox" name="extensions" value="permit-port-forwarding"> permit-port-forwarding</label>
<label class="checkbox-label"><input type="checkbox" name="extensions" value="permit-X11-forwarding"> permit-X11-forwarding</label>
<label class="checkbox-label"><input type="checkbox" name="extensions" value="permit-user-rc"> permit-user-rc</label>
</div>
</div>
<details>
<summary>Critical Options</summary>
<div class="form-row">
<div class="form-group">
<label for="profile_force_cmd">Force Command</label>
<input type="text" id="profile_force_cmd" name="force_command" placeholder="(none)">
</div>
<div class="form-group">
<label for="profile_source_addr">Source Address</label>
<input type="text" id="profile_source_addr" name="source_address" placeholder="(none)">
</div>
</div>
</details>
<div class="form-actions">
<button type="submit">Create Profile</button>
</div>
</form>
</details>
{{end}}
</div>
<div class="card">
<div class="card-title">Certificates</div>
{{if .Certs}}
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Serial</th>
<th>Type</th>
<th>Key ID</th>
<th>Principals</th>
<th>Issued By</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Certs}}
<tr>
<td><a href="/sshca/cert/{{.Serial}}"><code>{{.Serial}}</code></a></td>
<td>{{.CertType}}</td>
<td>{{.KeyID}}</td>
<td>{{.Principals}}</td>
<td>{{.IssuedBy}}</td>
<td>{{.ExpiresAt}}</td>
<td>{{if .Revoked}}<span class="badge badge-danger">revoked</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p>No certificates issued.</p>
{{end}}
</div>
{{if .IsAdmin}}
<div class="card">
<div class="card-title">Key Revocation List</div>
<p><a href="/v1/sshca/{{.MountName}}/krl" download="krl.bin">Download KRL</a></p>
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,60 @@
{{define "title"}} - SSH Certificate: {{.Cert.Serial}}{{end}}
{{define "content"}}
<div class="page-header">
<h2>SSH Certificate: <code>{{.Cert.Serial}}</code></h2>
<div class="page-meta">
<a href="/sshca">&larr; SSH CA</a>
</div>
</div>
<div class="card">
<div class="card-title">Details</div>
<table class="kv-table">
<tbody>
<tr><th>Serial</th><td><code>{{.Cert.Serial}}</code></td></tr>
<tr><th>Type</th><td>{{.Cert.CertType}}</td></tr>
<tr><th>Key ID</th><td>{{.Cert.KeyID}}</td></tr>
<tr><th>Principals</th><td>{{range .Cert.Principals}}<code>{{.}}</code> {{end}}</td></tr>
<tr><th>Profile</th><td>{{if .Cert.Profile}}<a href="/sshca/profile/{{.Cert.Profile}}">{{.Cert.Profile}}</a>{{else}}&mdash;{{end}}</td></tr>
<tr><th>Issued By</th><td>{{.Cert.IssuedBy}}</td></tr>
<tr><th>Issued At</th><td>{{.Cert.IssuedAt}}</td></tr>
<tr><th>Expires At</th><td>{{.Cert.ExpiresAt}}</td></tr>
<tr>
<th>Revoked</th>
<td>
{{if .Cert.Revoked}}
<span class="badge badge-danger">Revoked</span>
{{if .Cert.RevokedAt}} at {{.Cert.RevokedAt}}{{end}}
{{if .Cert.RevokedBy}} by {{.Cert.RevokedBy}}{{end}}
{{else}}
No
{{end}}
</td>
</tr>
</tbody>
</table>
</div>
{{if .Cert.CertData}}
<div class="card">
<div class="card-title">Certificate</div>
<textarea rows="6" class="pem-input" readonly>{{.Cert.CertData}}</textarea>
</div>
{{end}}
{{if .IsAdmin}}
<div class="card">
<div class="card-title">Admin Actions</div>
{{if not .Cert.Revoked}}
<form method="post" action="/sshca/cert/{{.Cert.Serial}}/revoke" style="display:inline;">
{{csrfField}}
<button type="submit" class="btn-danger" onclick="return confirm('Revoke certificate {{.Cert.Serial}}?')">Revoke</button>
</form>
{{end}}
<form method="post" action="/sshca/cert/{{.Cert.Serial}}/delete" style="display:inline; margin-left: 0.5rem;">
{{csrfField}}
<button type="submit" class="btn-danger" onclick="return confirm('Permanently delete certificate {{.Cert.Serial}}?')">Delete</button>
</form>
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,104 @@
{{define "title"}} - Profile: {{.Profile.Name}}{{end}}
{{define "content"}}
<div class="page-header">
<h2>Profile: {{.Profile.Name}}</h2>
<div class="page-meta">
<a href="/sshca">&larr; SSH CA</a>
</div>
</div>
<div class="card">
<div class="card-title">Configuration</div>
<table class="kv-table">
<tbody>
<tr><th>Name</th><td>{{.Profile.Name}}</td></tr>
<tr><th>Max TTL</th><td>{{if .Profile.MaxTTL}}{{.Profile.MaxTTL}}{{else}}&mdash;{{end}}</td></tr>
<tr>
<th>Allowed Principals</th>
<td>
{{if .Profile.AllowedPrincipals}}
{{range .Profile.AllowedPrincipals}}<code>{{.}}</code> {{end}}
{{else}}
(any)
{{end}}
</td>
</tr>
<tr>
<th>Extensions</th>
<td>
{{if .Profile.Extensions}}
{{range $k, $v := .Profile.Extensions}}<code>{{$k}}</code> {{end}}
{{else}}
(none)
{{end}}
</td>
</tr>
<tr>
<th>Critical Options</th>
<td>
{{if .Profile.CriticalOptions}}
{{range $k, $v := .Profile.CriticalOptions}}<code>{{$k}}{{if $v}}={{$v}}{{end}}</code> {{end}}
{{else}}
(none)
{{end}}
</td>
</tr>
</tbody>
</table>
</div>
{{if .IsAdmin}}
<div class="card">
<div class="card-title">Edit Profile</div>
<details>
<summary>Update configuration</summary>
<form method="post" action="/sshca/profile/{{.Profile.Name}}/update">
{{csrfField}}
<div class="form-group">
<label for="edit_max_ttl">Max TTL</label>
<input type="text" id="edit_max_ttl" name="max_ttl" value="{{.Profile.MaxTTL}}" placeholder="8760h">
</div>
<div class="form-group">
<label for="edit_principals">Allowed Principals <small style="text-transform:none;letter-spacing:0;">(one per line)</small></label>
<textarea id="edit_principals" name="allowed_principals" rows="3">{{range .Profile.AllowedPrincipals}}{{.}}
{{end}}</textarea>
</div>
<div class="form-group">
<label>Extensions</label>
<div class="checkbox-group">
<label class="checkbox-label"><input type="checkbox" name="extensions" value="permit-pty"{{if index .Profile.Extensions "permit-pty"}} checked{{end}}> permit-pty</label>
<label class="checkbox-label"><input type="checkbox" name="extensions" value="permit-agent-forwarding"{{if index .Profile.Extensions "permit-agent-forwarding"}} checked{{end}}> permit-agent-forwarding</label>
<label class="checkbox-label"><input type="checkbox" name="extensions" value="permit-port-forwarding"{{if index .Profile.Extensions "permit-port-forwarding"}} checked{{end}}> permit-port-forwarding</label>
<label class="checkbox-label"><input type="checkbox" name="extensions" value="permit-X11-forwarding"{{if index .Profile.Extensions "permit-X11-forwarding"}} checked{{end}}> permit-X11-forwarding</label>
<label class="checkbox-label"><input type="checkbox" name="extensions" value="permit-user-rc"{{if index .Profile.Extensions "permit-user-rc"}} checked{{end}}> permit-user-rc</label>
</div>
</div>
<details>
<summary>Critical Options</summary>
<div class="form-row">
<div class="form-group">
<label for="edit_force_cmd">Force Command</label>
<input type="text" id="edit_force_cmd" name="force_command" value="{{index .Profile.CriticalOptions "force-command"}}">
</div>
<div class="form-group">
<label for="edit_source_addr">Source Address</label>
<input type="text" id="edit_source_addr" name="source_address" value="{{index .Profile.CriticalOptions "source-address"}}">
</div>
</div>
</details>
<div class="form-actions">
<button type="submit">Update Profile</button>
</div>
</form>
</details>
</div>
<div class="card">
<div class="card-title">Admin Actions</div>
<form method="post" action="/sshca/profile/{{.Profile.Name}}/delete">
{{csrfField}}
<button type="submit" class="btn-danger" onclick="return confirm('Delete profile {{.Profile.Name}}?')">Delete Profile</button>
</form>
</div>
{{end}}
{{end}}

265
web/templates/transit.html Normal file
View File

@@ -0,0 +1,265 @@
{{define "title"}} - Transit: {{.MountName}}{{end}}
{{define "content"}}
<div class="page-header">
<h2>Transit: {{.MountName}}</h2>
<div class="page-meta">
<a href="/dashboard">&larr; Dashboard</a>
</div>
</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<div class="card">
<div class="card-title">Named Keys</div>
{{if .Keys}}
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Keys}}
<tr>
<td><a href="/transit/key/{{.Name}}">{{.Name}}</a></td>
<td><a href="/transit/key/{{.Name}}">View</a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p>No keys configured.</p>
{{end}}
{{if .IsAdmin}}
<details style="margin-top: 1rem;">
<summary>Create Key</summary>
<form method="post" action="/transit/key/create">
{{csrfField}}
<div class="form-row">
<div class="form-group">
<label for="key_name">Name</label>
<input type="text" id="key_name" name="name" placeholder="payments" required>
</div>
<div class="form-group">
<label for="key_type">Type</label>
<select id="key_type" name="type">
<option value="aes256-gcm">aes256-gcm (default)</option>
<option value="chacha20-poly">chacha20-poly</option>
<option value="ed25519">ed25519</option>
<option value="ecdsa-p256">ecdsa-p256</option>
<option value="ecdsa-p384">ecdsa-p384</option>
<option value="hmac-sha256">hmac-sha256</option>
<option value="hmac-sha512">hmac-sha512</option>
</select>
</div>
</div>
<div class="form-actions">
<button type="submit">Create Key</button>
</div>
</form>
</details>
{{end}}
</div>
<div class="card">
<div class="card-title">Encrypt</div>
{{if .EncryptResult}}
<div class="success">
<p>Encrypted successfully.</p>
<div class="form-group">
<label>Ciphertext</label>
<textarea rows="3" class="pem-input" readonly>{{.EncryptResult}}</textarea>
</div>
</div>
{{end}}
<form method="post" action="/transit/encrypt">
{{csrfField}}
<div class="form-row">
<div class="form-group">
<label for="enc_key">Key</label>
<select id="enc_key" name="key" required>
<option value="">&mdash; select key &mdash;</option>
{{range .Keys}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="form-group">
<label for="enc_ctx">Context <small style="text-transform:none;letter-spacing:0;">(optional AAD)</small></label>
<input type="text" id="enc_ctx" name="context" placeholder="base64-encoded">
</div>
</div>
<div class="form-group">
<label for="enc_plaintext">Plaintext <small style="text-transform:none;letter-spacing:0;">(base64)</small></label>
<textarea id="enc_plaintext" name="plaintext" rows="3" class="pem-input" required></textarea>
</div>
<div class="form-actions">
<button type="submit">Encrypt</button>
</div>
</form>
</div>
<div class="card">
<div class="card-title">Decrypt</div>
{{if .DecryptResult}}
<div class="success">
<p>Decrypted successfully.</p>
<div class="form-group">
<label>Plaintext (base64)</label>
<textarea rows="3" class="pem-input" readonly>{{.DecryptResult}}</textarea>
</div>
</div>
{{end}}
<form method="post" action="/transit/decrypt">
{{csrfField}}
<div class="form-row">
<div class="form-group">
<label for="dec_key">Key</label>
<select id="dec_key" name="key" required>
<option value="">&mdash; select key &mdash;</option>
{{range .Keys}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="form-group">
<label for="dec_ctx">Context <small style="text-transform:none;letter-spacing:0;">(optional)</small></label>
<input type="text" id="dec_ctx" name="context">
</div>
</div>
<div class="form-group">
<label for="dec_ciphertext">Ciphertext</label>
<textarea id="dec_ciphertext" name="ciphertext" rows="3" class="pem-input" placeholder="metacrypt:v1:..." required></textarea>
</div>
<div class="form-actions">
<button type="submit">Decrypt</button>
</div>
</form>
</div>
<div class="card">
<div class="card-title">Rewrap</div>
{{if .RewrapResult}}
<div class="success">
<p>Re-wrapped successfully.</p>
<div class="form-group">
<label>New Ciphertext</label>
<textarea rows="3" class="pem-input" readonly>{{.RewrapResult}}</textarea>
</div>
</div>
{{end}}
<form method="post" action="/transit/rewrap">
{{csrfField}}
<div class="form-row">
<div class="form-group">
<label for="rew_key">Key</label>
<select id="rew_key" name="key" required>
<option value="">&mdash; select key &mdash;</option>
{{range .Keys}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="form-group">
<label for="rew_ctx">Context <small style="text-transform:none;letter-spacing:0;">(optional)</small></label>
<input type="text" id="rew_ctx" name="context">
</div>
</div>
<div class="form-group">
<label for="rew_ciphertext">Ciphertext</label>
<textarea id="rew_ciphertext" name="ciphertext" rows="3" class="pem-input" placeholder="metacrypt:v1:..." required></textarea>
</div>
<div class="form-actions">
<button type="submit">Rewrap</button>
</div>
</form>
</div>
<div class="card">
<div class="card-title">Sign</div>
{{if .SignResult}}
<div class="success">
<p>Signed successfully.</p>
<div class="form-group">
<label>Signature</label>
<textarea rows="3" class="pem-input" readonly>{{.SignResult}}</textarea>
</div>
</div>
{{end}}
<form method="post" action="/transit/sign">
{{csrfField}}
<div class="form-group">
<label for="sig_key">Key</label>
<select id="sig_key" name="key" required>
<option value="">&mdash; select signing key &mdash;</option>
{{range .Keys}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="form-group">
<label for="sig_input">Input <small style="text-transform:none;letter-spacing:0;">(base64)</small></label>
<textarea id="sig_input" name="input" rows="3" class="pem-input" required></textarea>
</div>
<div class="form-actions">
<button type="submit">Sign</button>
</div>
</form>
</div>
<div class="card">
<div class="card-title">Verify</div>
{{if .VerifyResult}}
<div class="{{if .VerifyResult}}success{{else}}error{{end}}">
{{if .VerifyResult}}<p>Signature is valid.</p>{{else}}<p>Signature is invalid.</p>{{end}}
</div>
{{end}}
<form method="post" action="/transit/verify">
{{csrfField}}
<div class="form-group">
<label for="ver_key">Key</label>
<select id="ver_key" name="key" required>
<option value="">&mdash; select signing key &mdash;</option>
{{range .Keys}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="form-group">
<label for="ver_input">Input <small style="text-transform:none;letter-spacing:0;">(base64)</small></label>
<textarea id="ver_input" name="input" rows="3" class="pem-input" required></textarea>
</div>
<div class="form-group">
<label for="ver_sig">Signature</label>
<textarea id="ver_sig" name="signature" rows="3" class="pem-input" placeholder="metacrypt:v1:..." required></textarea>
</div>
<div class="form-actions">
<button type="submit">Verify</button>
</div>
</form>
</div>
<div class="card">
<div class="card-title">HMAC</div>
{{if .HMACResult}}
<div class="success">
<p>HMAC computed successfully.</p>
<div class="form-group">
<label>HMAC</label>
<textarea rows="2" class="pem-input" readonly>{{.HMACResult}}</textarea>
</div>
</div>
{{end}}
<form method="post" action="/transit/hmac">
{{csrfField}}
<div class="form-group">
<label for="hmac_key">Key</label>
<select id="hmac_key" name="key" required>
<option value="">&mdash; select HMAC key &mdash;</option>
{{range .Keys}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="form-group">
<label for="hmac_input">Input <small style="text-transform:none;letter-spacing:0;">(base64)</small></label>
<textarea id="hmac_input" name="input" rows="3" class="pem-input" required></textarea>
</div>
<div class="form-actions">
<button type="submit">Generate HMAC</button>
</div>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,103 @@
{{define "title"}} - Key: {{.Key.Name}}{{end}}
{{define "content"}}
<div class="page-header">
<h2>Key: {{.Key.Name}}</h2>
<div class="page-meta">
<a href="/transit">&larr; Transit</a>
</div>
</div>
<div class="card">
<div class="card-title">Key Details</div>
<table class="kv-table">
<tbody>
<tr><th>Name</th><td>{{.Key.Name}}</td></tr>
<tr><th>Type</th><td><code>{{.Key.Type}}</code></td></tr>
<tr><th>Current Version</th><td>{{.Key.CurrentVersion}}</td></tr>
<tr><th>Min Decryption Version</th><td>{{.Key.MinDecryptionVersion}}</td></tr>
<tr><th>Allow Deletion</th><td>{{if .Key.AllowDeletion}}yes{{else}}no{{end}}</td></tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-title">Key Versions</div>
{{if .Key.Versions}}
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Version</th>
</tr>
</thead>
<tbody>
{{range .Key.Versions}}
<tr>
<td>{{.}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p>No version information available.</p>
{{end}}
</div>
{{if .PublicKey}}
<div class="card">
<div class="card-title">Public Key</div>
<textarea rows="4" class="pem-input" readonly>{{.PublicKey}}</textarea>
</div>
{{end}}
{{if .IsAdmin}}
<div class="card">
<div class="card-title">Admin Actions</div>
<form method="post" action="/transit/key/{{.Key.Name}}/rotate" style="display:inline;">
{{csrfField}}
<button type="submit" onclick="return confirm('Rotate key {{.Key.Name}}?')">Rotate Key</button>
</form>
<details style="margin-top: 1rem;">
<summary>Update Config</summary>
<form method="post" action="/transit/key/{{.Key.Name}}/config">
{{csrfField}}
<div class="form-row">
<div class="form-group">
<label for="cfg_min_decrypt">Min Decryption Version</label>
<input type="number" id="cfg_min_decrypt" name="min_decryption_version" value="{{.Key.MinDecryptionVersion}}" min="0">
</div>
<div class="form-group">
<label>&nbsp;</label>
<label class="checkbox-label"><input type="checkbox" name="allow_deletion"{{if .Key.AllowDeletion}} checked{{end}}> Allow Deletion</label>
</div>
</div>
<div class="form-actions">
<button type="submit">Update Config</button>
</div>
</form>
</details>
<details style="margin-top: 1rem;">
<summary>Trim Old Versions</summary>
<form method="post" action="/transit/key/{{.Key.Name}}/trim">
{{csrfField}}
<p>Trims key versions below the current min_decryption_version.</p>
<div class="form-actions">
<button type="submit" class="btn-danger" onclick="return confirm('Trim old versions of key {{.Key.Name}}? This cannot be undone.')">Trim Versions</button>
</div>
</form>
</details>
{{if .Key.AllowDeletion}}
<div style="margin-top: 1rem;">
<form method="post" action="/transit/key/{{.Key.Name}}/delete">
{{csrfField}}
<button type="submit" class="btn-danger" onclick="return confirm('Permanently delete key {{.Key.Name}}? This cannot be undone.')">Delete Key</button>
</form>
</div>
{{end}}
</div>
{{end}}
{{end}}

162
web/templates/user.html Normal file
View File

@@ -0,0 +1,162 @@
{{define "title"}} - User Crypto: {{.MountName}}{{end}}
{{define "content"}}
<div class="page-header">
<h2>User Crypto: {{.MountName}}</h2>
<div class="page-meta">
<a href="/dashboard">&larr; Dashboard</a>
</div>
</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<div class="card">
<div class="card-title">Your Key</div>
{{if .OwnKey}}
<table class="kv-table">
<tbody>
<tr><th>Username</th><td>{{.OwnKey.Username}}</td></tr>
<tr><th>Algorithm</th><td><code>{{.OwnKey.Algorithm}}</code></td></tr>
<tr><th>Public Key</th><td><code style="word-break:break-all;">{{.OwnKey.PublicKey}}</code></td></tr>
</tbody>
</table>
{{else}}
<p>You have no keypair registered.</p>
<form method="post" action="/user/register">
{{csrfField}}
<div class="form-actions">
<button type="submit">Register</button>
</div>
</form>
{{end}}
</div>
<div class="card">
<div class="card-title">Encrypt</div>
{{if .EncryptResult}}
<div class="success">
<p>Encrypted successfully.</p>
<div class="form-group">
<label>Envelope (JSON)</label>
<textarea rows="8" class="pem-input" readonly>{{.EncryptResult}}</textarea>
</div>
</div>
{{end}}
<form method="post" action="/user/encrypt">
{{csrfField}}
<div class="form-group">
<label for="enc_recipients">Recipients <small style="text-transform:none;letter-spacing:0;">(one username per line)</small></label>
<textarea id="enc_recipients" name="recipients" rows="2" required></textarea>
</div>
<div class="form-group">
<label for="enc_plaintext">Plaintext <small style="text-transform:none;letter-spacing:0;">(base64)</small></label>
<textarea id="enc_plaintext" name="plaintext" rows="3" class="pem-input" required></textarea>
</div>
<div class="form-group">
<label for="enc_metadata">Metadata <small style="text-transform:none;letter-spacing:0;">(optional, authenticated but unencrypted)</small></label>
<input type="text" id="enc_metadata" name="metadata" placeholder="">
</div>
<div class="form-actions">
<button type="submit">Encrypt</button>
</div>
</form>
</div>
<div class="card">
<div class="card-title">Decrypt</div>
{{if .DecryptResult}}
<div class="success">
<p>Decrypted successfully. Sender: <code>{{.DecryptResult.Sender}}</code>{{if .DecryptResult.Metadata}} &mdash; Metadata: {{.DecryptResult.Metadata}}{{end}}</p>
<div class="form-group">
<label>Plaintext (base64)</label>
<textarea rows="3" class="pem-input" readonly>{{.DecryptResult.Plaintext}}</textarea>
</div>
</div>
{{end}}
<form method="post" action="/user/decrypt">
{{csrfField}}
<div class="form-group">
<label for="dec_envelope">Envelope (JSON)</label>
<textarea id="dec_envelope" name="envelope" rows="6" class="pem-input" required></textarea>
</div>
<div class="form-actions">
<button type="submit">Decrypt</button>
</div>
</form>
</div>
<div class="card">
<div class="card-title">Re-Encrypt</div>
{{if .ReEncryptResult}}
<div class="success">
<p>Re-encrypted successfully.</p>
<div class="form-group">
<label>Updated Envelope (JSON)</label>
<textarea rows="8" class="pem-input" readonly>{{.ReEncryptResult}}</textarea>
</div>
</div>
{{end}}
<form method="post" action="/user/re-encrypt">
{{csrfField}}
<div class="form-group">
<label for="reenc_envelope">Envelope (JSON)</label>
<textarea id="reenc_envelope" name="envelope" rows="6" class="pem-input" required></textarea>
</div>
<div class="form-actions">
<button type="submit">Re-Encrypt</button>
</div>
</form>
</div>
{{if .OwnKey}}
<div class="card">
<div class="card-title">Key Rotation</div>
<p>Rotating your key generates a new keypair. Existing envelopes encrypted to your old key will become unreadable unless re-encrypted first.</p>
<form method="post" action="/user/rotate">
{{csrfField}}
<button type="submit" class="btn-danger" onclick="return confirm('Rotate your key? Existing envelopes will be unreadable unless re-encrypted first.')">Rotate Key</button>
</form>
</div>
{{end}}
<div class="card">
<div class="card-title">Registered Users</div>
{{if .Users}}
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Username</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td><a href="/user/key/{{.}}">{{.}}</a></td>
<td><a href="/user/key/{{.}}">View Key</a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p>No users registered.</p>
{{end}}
{{if .IsAdmin}}
<details style="margin-top: 1rem;">
<summary>Provision User</summary>
<form method="post" action="/user/provision">
{{csrfField}}
<div class="form-group">
<label for="prov_username">Username</label>
<input type="text" id="prov_username" name="username" placeholder="alice" required>
</div>
<div class="form-actions">
<button type="submit">Provision</button>
</div>
</form>
</details>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,30 @@
{{define "title"}} - User Key: {{.KeyInfo.Username}}{{end}}
{{define "content"}}
<div class="page-header">
<h2>User Key: {{.KeyInfo.Username}}</h2>
<div class="page-meta">
<a href="/user">&larr; User Crypto</a>
</div>
</div>
<div class="card">
<div class="card-title">Public Key</div>
<table class="kv-table">
<tbody>
<tr><th>Username</th><td>{{.KeyInfo.Username}}</td></tr>
<tr><th>Algorithm</th><td><code>{{.KeyInfo.Algorithm}}</code></td></tr>
<tr><th>Public Key</th><td><code style="word-break:break-all;">{{.KeyInfo.PublicKey}}</code></td></tr>
</tbody>
</table>
</div>
{{if .IsAdmin}}
<div class="card">
<div class="card-title">Admin Actions</div>
<form method="post" action="/user/delete/{{.KeyInfo.Username}}">
{{csrfField}}
<button type="submit" class="btn-danger" onclick="return confirm('Delete user {{.KeyInfo.Username}} and their keys? This cannot be undone.')">Delete User</button>
</form>
</div>
{{end}}
{{end}}