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

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"os"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
@@ -17,13 +18,16 @@ import (
// VaultClient wraps the gRPC stubs for communicating with the vault.
type VaultClient struct {
conn *grpc.ClientConn
system pb.SystemServiceClient
auth pb.AuthServiceClient
engine pb.EngineServiceClient
pki pb.PKIServiceClient
ca pb.CAServiceClient
policy pb.PolicyServiceClient
conn *grpc.ClientConn
system pb.SystemServiceClient
auth pb.AuthServiceClient
engine pb.EngineServiceClient
pki pb.PKIServiceClient
ca pb.CAServiceClient
policy pb.PolicyServiceClient
sshca pb.SSHCAServiceClient
transit pb.TransitServiceClient
user pb.UserServiceClient
}
// NewVaultClient dials the vault gRPC server and returns a client.
@@ -55,13 +59,16 @@ func NewVaultClient(addr, caCertPath string, logger *slog.Logger) (*VaultClient,
logger.Debug("vault gRPC connection established", "addr", addr)
return &VaultClient{
conn: conn,
system: pb.NewSystemServiceClient(conn),
auth: pb.NewAuthServiceClient(conn),
engine: pb.NewEngineServiceClient(conn),
pki: pb.NewPKIServiceClient(conn),
ca: pb.NewCAServiceClient(conn),
policy: pb.NewPolicyServiceClient(conn),
conn: conn,
system: pb.NewSystemServiceClient(conn),
auth: pb.NewAuthServiceClient(conn),
engine: pb.NewEngineServiceClient(conn),
pki: pb.NewPKIServiceClient(conn),
ca: pb.NewCAServiceClient(conn),
policy: pb.NewPolicyServiceClient(conn),
sshca: pb.NewSSHCAServiceClient(conn),
transit: pb.NewTransitServiceClient(conn),
user: pb.NewUserServiceClient(conn),
}, nil
}
@@ -496,3 +503,610 @@ func (c *VaultClient) ListCerts(ctx context.Context, token, mount string) ([]Cer
}
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
}