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

@@ -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 }
// 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 }
// ---- handleTGZDownload tests ----

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
}

View File

@@ -38,6 +38,7 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.HandleFunc("/login", ws.handleLogin)
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
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.Get("/", ws.requireAuth(ws.handlePolicy))
@@ -45,6 +46,47 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
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.Get("/", ws.requireAuth(ws.handlePKI))
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)
mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": state,
})
data := ws.baseData(r, info)
data["Roles"] = info.Roles
data["Mounts"] = mounts
data["State"] = state
ws.renderTemplate(w, "dashboard.html", data)
}
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)
}
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) {
token := extractCookie(r)
mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": state,
"MountError": errMsg,
})
data := ws.baseData(r, info)
data["Roles"] = info.Roles
data["Mounts"] = mounts
data["State"] = state
data["MountError"] = errMsg
ws.renderTemplate(w, "dashboard.html", data)
}
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
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
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)
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"IssuerName": issuerName,
"Certs": certs,
"NameFilter": r.URL.Query().Get("name"),
"SortBy": sortBy,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["IssuerName"] = issuerName
data["Certs"] = certs
data["NameFilter"] = r.URL.Query().Get("name")
data["SortBy"] = sortBy
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.RevokedBy = ws.resolveUser(cert.RevokedBy)
ws.renderTemplate(w, "cert_detail.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Cert": cert,
})
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Cert"] = cert
ws.renderTemplate(w, "cert_detail.html", data)
}
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
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"SignedCert": signed,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["SignedCert"] = signed
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
data["RootCN"] = cert.Subject.CommonName
@@ -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) {
token := extractCookie(r)
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Error": errMsg,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Error"] = errMsg
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
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) {
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)
if err != nil {
return "", err
}
for _, m := range mounts {
if m.Type == "ca" {
if m.Type == engineType {
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) {
@@ -848,11 +942,9 @@ func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
if err != nil {
rules = []PolicyRule{}
}
ws.renderTemplate(w, "policy.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Rules": rules,
})
data := ws.baseData(r, info)
data["Rules"] = rules
ws.renderTemplate(w, "policy.html", data)
}
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) {
rules, _ := ws.vault.ListPolicies(r.Context(), token)
ws.renderTemplate(w, "policy.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Rules": rules,
"Error": errMsg,
})
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,
"IsAdmin": info.IsAdmin,
"HasSSHCA": types["sshca"],
"HasTransit": types["transit"],
"HasUser": types["user"],
}
}
// 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.
// It is satisfied by *VaultClient and can be replaced with a mock in tests.
type vaultBackend interface {
// System
Status(ctx context.Context) (string, error)
Init(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)
ListMounts(ctx context.Context, token string) ([]MountInfo, 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)
GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, 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)
RevokeCert(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)
GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error)
CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, 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

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