diff --git a/API_DOCS.md b/API_DOCS.md index ed689be..c221a3a 100644 --- a/API_DOCS.md +++ b/API_DOCS.md @@ -23,3 +23,25 @@ PATCH `/api/v1/servers/{server_id}` "display_name": "Keywarden Prod" } ``` + +## SSH user certificates (OpenSSH CA) + +Keywarden signs user SSH keys with an OpenSSH certificate authority. The flow is: +- User uploads a public key (`POST /api/v1/keys`). +- Server signs the key using the active user CA. +- Certificate is stored server-side and can be downloaded by the user. + +Endpoints: +- `POST /api/v1/keys/{key_id}/certificate` issues (or re-issues) a certificate. +- `GET /api/v1/keys/{key_id}/certificate` downloads the certificate. +- `GET /api/v1/keys/{key_id}/certificate.sha256` downloads a sha256 hash file. + +Agent endpoints (mTLS): +- `GET /api/v1/agent/servers/{server_id}/ssh-ca` returns the CA public key for agent install. +- `GET /api/v1/agent/servers/{server_id}/accounts` returns account + system username (no raw keys). + +Configuration: +- `KEYWARDEN_USER_CERT_VALIDITY_DAYS` controls certificate lifetime (default: 30 days). +- `KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE` controls account name derivation. + +Note: `ssh-keygen` must be available on the Keywarden server to sign certificates. diff --git a/Dockerfile b/Dockerfile index 38f4f35..480e502 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libpq-dev \ curl \ openssl \ + openssh-client \ nginx \ nodejs \ npm \ diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..343b090 --- /dev/null +++ b/TODO.md @@ -0,0 +1,18 @@ +Next steps: + +Certificate Generation: +- User account is created +- User can input SSH pubkey into profile page +- Keywarden creates signed SSH Certificate from User's pubkey and Keywarden CA + +Grant: +- User requests access to target server +- Access request approved +- User has linux account created and has key / cert trusted by target server +- User can log into account + +Revocation: +- User has access expire or revoked +- Keywarden removes key / cert from target server, or invalidates on Keywarden's side +- Keywarden removes object permissions +- User cannot access server anymore diff --git a/agent/README.md b/agent/README.md index b88b694..24219e9 100644 --- a/agent/README.md +++ b/agent/README.md @@ -1,6 +1,8 @@ +TODO: Move to boris/keywarden-agent. In main repo for now for development. + # keywarden-agent -Minimal Go agent scaffold for Keywarden. +Minimal Go agent for Keywarden. ## Build diff --git a/agent/internal/accounts/sync.go b/agent/internal/accounts/sync.go index bb3c0f3..d0d3cfe 100644 --- a/agent/internal/accounts/sync.go +++ b/agent/internal/accounts/sync.go @@ -15,18 +15,24 @@ import ( ) const ( - stateFileName = "accounts.json" - maxUsernameLen = 32 - passwdFilePath = "/etc/passwd" - sshDirName = ".ssh" - authKeysName = "authorized_keys" + stateFileName = "accounts.json" + maxUsernameLen = 32 + passwdFilePath = "/etc/passwd" + groupFilePath = "/etc/group" + sshDirName = ".ssh" + authKeysName = "authorized_keys" + keywardenGroup = "keywarden" + userCAPath = "/etc/ssh/keywarden_user_ca.pub" + sshdConfigDropDir = "/etc/ssh/sshd_config.d" + sshdConfigDropIn = "/etc/ssh/sshd_config.d/keywarden.conf" + sshdConfigPath = "/etc/ssh/sshd_config" ) type AccessUser struct { - UserID int - Username string - Email string - Keys []string + UserID int + Username string + Email string + SystemUsername string } type ReportAccount struct { @@ -65,14 +71,18 @@ func Sync(policy config.AccountPolicy, stateDir string, users []AccessUser) (Res } desired := make(map[int]managedAccount, len(users)) - userIndex := make(map[int]AccessUser, len(users)) for _, user := range users { - systemUser := renderUsername(policy.UsernameTemplate, user.Username, user.UserID) + systemUser := user.SystemUsername + if strings.TrimSpace(systemUser) == "" { + systemUser = renderUsername(policy.UsernameTemplate, user.Username, user.UserID) + } desired[user.UserID] = managedAccount{UserID: user.UserID, SystemUser: systemUser} - userIndex[user.UserID] = user } var syncErr error + if err := ensureGroup(keywardenGroup); err != nil && syncErr == nil { + syncErr = err + } for _, account := range current.Users { if _, ok := desired[account.UserID]; ok { continue @@ -84,8 +94,7 @@ func Sync(policy config.AccountPolicy, stateDir string, users []AccessUser) (Res } for userID, account := range desired { - accessUser := userIndex[userID] - present, err := ensureAccount(account.SystemUser, policy, accessUser.Keys) + present, err := ensureAccount(account.SystemUser, policy) if err != nil && syncErr == nil { syncErr = err } @@ -186,7 +195,7 @@ func userExists(username string) (bool, error) { return true, nil } -func ensureAccount(username string, policy config.AccountPolicy, keys []string) (bool, error) { +func ensureAccount(username string, policy config.AccountPolicy) (bool, error) { exists, err := userExists(username) if err != nil { return false, err @@ -196,17 +205,20 @@ func ensureAccount(username string, policy config.AccountPolicy, keys []string) return false, err } } - if err := lockPassword(username); err != nil { + if err := ensureGroupMembership(username, keywardenGroup); err != nil { return true, err } - if err := writeAuthorizedKeys(username, keys); err != nil { + if err := enforceCertificateOnly(username, policy); err != nil { + return true, err + } + if err := writeAuthorizedKeys(username, nil); err != nil { return true, err } return true, nil } func createUser(username string, policy config.AccountPolicy) error { - args := []string{"-U"} + args := []string{"-U", "-G", keywardenGroup} if policy.CreateHome { args = append(args, "-m") } else { @@ -223,10 +235,20 @@ func createUser(username string, policy config.AccountPolicy) error { return nil } -func lockPassword(username string) error { +func enforceCertificateOnly(username string, policy config.AccountPolicy) error { cmd := exec.Command("usermod", "-L", username) if err := cmd.Run(); err != nil { - return fmt.Errorf("lock password %s: %w", username, err) + return fmt.Errorf("lock account %s: %w", username, err) + } + if policy.DefaultShell != "" { + shellCmd := exec.Command("usermod", "-s", policy.DefaultShell, username) + if err := shellCmd.Run(); err != nil { + return fmt.Errorf("set shell %s: %w", username, err) + } + } + expiryCmd := exec.Command("chage", "-E", "-1", username) + if err := expiryCmd.Run(); err != nil { + return fmt.Errorf("clear expiry %s: %w", username, err) } return nil } @@ -241,7 +263,7 @@ func revokeUser(username string, policy config.AccountPolicy) error { } var revokeErr error if policy.LockOnRevoke { - if err := lockPassword(username); err != nil { + if err := disableAccount(username); err != nil { revokeErr = err } } @@ -251,6 +273,157 @@ func revokeUser(username string, policy config.AccountPolicy) error { return revokeErr } +func disableAccount(username string) error { + cmd := exec.Command("usermod", "-L", username) + if err := cmd.Run(); err != nil { + return fmt.Errorf("lock account %s: %w", username, err) + } + expiryCmd := exec.Command("chage", "-E", "0", username) + if err := expiryCmd.Run(); err != nil { + return fmt.Errorf("expire account %s: %w", username, err) + } + return nil +} + +func ensureGroup(name string) error { + exists, err := groupExists(name) + if err != nil { + return err + } + if exists { + return nil + } + cmd := exec.Command("groupadd", name) + if err := cmd.Run(); err != nil { + return fmt.Errorf("groupadd %s: %w", name, err) + } + return nil +} + +func groupExists(name string) (bool, error) { + file, err := os.Open(groupFilePath) + if err != nil { + return false, fmt.Errorf("open group file: %w", err) + } + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.SplitN(line, ":", 4) + if len(fields) < 1 { + continue + } + if fields[0] == name { + return true, nil + } + } + if err := scanner.Err(); err != nil { + return false, fmt.Errorf("scan group file: %w", err) + } + return false, nil +} + +func ensureGroupMembership(username string, group string) error { + cmd := exec.Command("usermod", "-a", "-G", group, username) + if err := cmd.Run(); err != nil { + return fmt.Errorf("usermod add %s to %s: %w", username, group, err) + } + return nil +} + +func EnsureCA(publicKey string) error { + key := strings.TrimSpace(publicKey) + if key == "" { + return errors.New("user CA public key required") + } + changed, err := writeCAKeyIfChanged(key) + if err != nil { + return err + } + configChanged, err := ensureSSHDConfig() + if err != nil { + return err + } + if changed || configChanged { + if err := reloadSSHD(); err != nil { + return err + } + } + return nil +} + +func writeCAKeyIfChanged(key string) (bool, error) { + if data, err := os.ReadFile(userCAPath); err == nil { + if strings.TrimSpace(string(data)) == key { + return false, nil + } + } else if !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("read user CA key: %w", err) + } + if err := os.WriteFile(userCAPath, []byte(key+"\n"), 0o644); err != nil { + return false, fmt.Errorf("write user CA key: %w", err) + } + return true, nil +} + +func ensureSSHDConfig() (bool, error) { + content := fmt.Sprintf( + "TrustedUserCAKeys %s\nMatch Group %s\n AuthorizedKeysFile none\n", + userCAPath, + keywardenGroup, + ) + if info, err := os.Stat(sshdConfigDropDir); err == nil && info.IsDir() { + if existing, err := os.ReadFile(sshdConfigDropIn); err == nil { + if string(existing) == content { + return false, nil + } + } + if err := os.WriteFile(sshdConfigDropIn, []byte(content), 0o644); err != nil { + return false, fmt.Errorf("write sshd drop-in: %w", err) + } + return true, nil + } + data, err := os.ReadFile(sshdConfigPath) + if err != nil { + return false, fmt.Errorf("read sshd config: %w", err) + } + if strings.Contains(string(data), "TrustedUserCAKeys "+userCAPath) { + return false, nil + } + updated := string(data) + if !strings.HasSuffix(updated, "\n") { + updated += "\n" + } + updated += "\n# Keywarden managed users\n" + content + if err := os.WriteFile(sshdConfigPath, []byte(updated), 0o644); err != nil { + return false, fmt.Errorf("write sshd config: %w", err) + } + return true, nil +} + +func reloadSSHD() error { + if path, _ := exec.LookPath("systemctl"); path != "" { + if err := exec.Command("systemctl", "reload", "sshd").Run(); err == nil { + return nil + } + if err := exec.Command("systemctl", "reload", "ssh").Run(); err == nil { + return nil + } + } + if path, _ := exec.LookPath("service"); path != "" { + if err := exec.Command("service", "sshd", "reload").Run(); err == nil { + return nil + } + if err := exec.Command("service", "ssh", "reload").Run(); err == nil { + return nil + } + } + return errors.New("unable to reload sshd") +} + func writeAuthorizedKeys(username string, keys []string) error { entry, err := lookupUser(username) if err != nil { diff --git a/agent/internal/client/client.go b/agent/internal/client/client.go index f71c34b..6743ea0 100644 --- a/agent/internal/client/client.go +++ b/agent/internal/client/client.go @@ -88,10 +88,16 @@ type AccountKey struct { } type AccountAccess struct { - UserID int `json:"user_id"` - Username string `json:"username"` - Email string `json:"email"` - Keys []AccountKey `json:"keys"` + UserID int `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + SystemUsername string `json:"system_username"` + Keys []AccountKey `json:"keys"` +} + +type UserCAResponse struct { + PublicKey string `json:"public_key"` + Fingerprint string `json:"fingerprint"` } type AccountSyncEntry struct { @@ -145,24 +151,24 @@ func (c *Client) SyncAccounts(ctx context.Context, cfg *config.Config) error { if cfg == nil { return errors.New("config required for account sync") } + ca, err := c.FetchUserCA(ctx, cfg.ServerID) + if err != nil { + return err + } + if err := accounts.EnsureCA(ca.PublicKey); err != nil { + return err + } users, err := c.FetchAccountAccess(ctx, cfg.ServerID) if err != nil { return err } accessUsers := make([]accounts.AccessUser, 0, len(users)) for _, user := range users { - keys := make([]string, 0, len(user.Keys)) - for _, key := range user.Keys { - if strings.TrimSpace(key.PublicKey) == "" { - continue - } - keys = append(keys, strings.TrimSpace(key.PublicKey)) - } accessUsers = append(accessUsers, accounts.AccessUser{ - UserID: user.UserID, - Username: user.Username, - Email: user.Email, - Keys: keys, + UserID: user.UserID, + Username: user.Username, + Email: user.Email, + SystemUsername: user.SystemUsername, }) } result, syncErr := accounts.Sync(cfg.AccountPolicy, cfg.StateDir, accessUsers) @@ -215,6 +221,34 @@ func (c *Client) FetchAccountAccess(ctx context.Context, serverID string) ([]Acc return out, nil } +func (c *Client) FetchUserCA(ctx context.Context, serverID string) (*UserCAResponse, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + c.baseURL+"/agent/servers/"+serverID+"/ssh-ca", + nil, + ) + if err != nil { + return nil, fmt.Errorf("build user ca request: %w", err) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch user ca: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status} + } + var out UserCAResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("decode user ca: %w", err) + } + if strings.TrimSpace(out.PublicKey) == "" { + return nil, errors.New("user ca missing public key") + } + return &out, nil +} + func (c *Client) SendSyncReport(ctx context.Context, serverID string, report SyncReportRequest) error { body, err := json.Marshal(report) if err != nil { diff --git a/agent/keywarden-agent b/agent/keywarden-agent index 3072405..fd19247 100755 Binary files a/agent/keywarden-agent and b/agent/keywarden-agent differ diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index 2834069..16faf07 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -14,3 +14,26 @@ class ErasureRequestForm(forms.Form): min_length=10, max_length=2000, ) + + +class SSHKeyForm(forms.Form): + name = forms.CharField( + label="Key name", + max_length=128, + widget=forms.TextInput( + attrs={ + "placeholder": "MacBook Pro", + "class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600", + } + ), + ) + public_key = forms.CharField( + label="SSH public key", + widget=forms.Textarea( + attrs={ + "rows": 4, + "placeholder": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB... you@host", + "class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600", + } + ), + ) diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index 4aae593..fd6864d 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -78,7 +78,7 @@ class ErasureRequest(models.Model): from guardian.models import UserObjectPermission from apps.access.models import AccessRequest - from apps.keys.models import SSHKey + from apps.keys.models import SSHCertificate, SSHKey user = self.user token = uuid.uuid4().hex @@ -113,6 +113,7 @@ class ErasureRequest(models.Model): UserObjectPermission.objects.filter(user=user).delete() SSHKey.objects.filter(user=user, is_active=True).update(is_active=False, revoked_at=now) + SSHCertificate.objects.filter(user=user, is_active=True).update(is_active=False, revoked_at=now) AccessRequest.objects.filter(requester=user).update(reason="[redacted]") AccessRequest.objects.filter( requester=user, diff --git a/app/apps/accounts/templates/accounts/profile.html b/app/apps/accounts/templates/accounts/profile.html index 6fa5e3c..0d74010 100644 --- a/app/apps/accounts/templates/accounts/profile.html +++ b/app/apps/accounts/templates/accounts/profile.html @@ -46,6 +46,152 @@ +
+ Upload your SSH public key to receive a signed certificate for server access. +
+ + {% if can_add_key %} + + {% else %} +You do not have permission to add SSH keys.
+ {% endif %} + + {% if ssh_keys %} +{{ key.name }}
+{{ key.fingerprint }}
+No SSH keys uploaded yet.
+ {% endif %} +@@ -81,6 +227,7 @@ {% if not erasure_request or erasure_request.status != "pending" %}