Certificate generation and sync, implemented proper grant and revocation flows. Pubkey uploading. Added openssh-client to Dockerfile

This commit is contained in:
2026-01-26 23:27:18 +00:00
parent cdaceb1cf7
commit 664e7be9f0
23 changed files with 1119 additions and 66 deletions

View File

@@ -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.

View File

@@ -17,6 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
curl \
openssl \
openssh-client \
nginx \
nodejs \
npm \

18
TODO.md Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -18,15 +18,21 @@ const (
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
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 {

View File

@@ -91,9 +91,15 @@ type AccountAccess struct {
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 {
UserID int `json:"user_id"`
SystemUsername string `json:"system_username"`
@@ -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,
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 {

Binary file not shown.

View File

@@ -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",
}
),
)

View File

@@ -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,

View File

@@ -46,6 +46,152 @@
</div>
</div>
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h2 class="text-base font-semibold tracking-tight text-gray-900">SSH certificates</h2>
<p class="mt-2 text-sm text-gray-600">
Upload your SSH public key to receive a signed certificate for server access.
</p>
{% if can_add_key %}
<form method="post" class="mt-4 space-y-3">
{% csrf_token %}
<input type="hidden" name="form_type" value="ssh_key">
<div>
<label for="{{ key_form.name.id_for_label }}" class="block text-sm font-medium text-gray-700">
Key name
</label>
<div class="mt-1">
{{ key_form.name }}
</div>
{% if key_form.name.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p>
{% endif %}
</div>
<div>
<label for="{{ key_form.public_key.id_for_label }}" class="block text-sm font-medium text-gray-700">
SSH public key
</label>
<div class="mt-1">
{{ key_form.public_key }}
</div>
{% if key_form.public_key.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p>
{% endif %}
</div>
{% if key_form.non_field_errors %}
<p class="text-sm text-red-600">{{ key_form.non_field_errors|striptags }}</p>
{% endif %}
<button type="submit" class="inline-flex items-center rounded-md bg-purple-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">
Upload key
</button>
</form>
{% else %}
<p class="mt-4 text-sm text-gray-600">You do not have permission to add SSH keys.</p>
{% endif %}
{% if ssh_keys %}
<div class="mt-6 divide-y divide-gray-200">
{% for key in ssh_keys %}
<div class="flex items-center justify-between py-4">
<div>
<p class="text-sm font-semibold text-gray-900">{{ key.name }}</p>
<p class="text-xs text-gray-500">{{ key.fingerprint }}</p>
</div>
<div class="flex items-center gap-3">
{% if key.is_active %}
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
<button
type="button"
class="inline-flex items-center rounded-l-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
data-download-url="/api/v1/keys/{{ key.id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-md border border-l-0 border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-200"
data-download-url="/api/v1/keys/{{ key.id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 js-regenerate-cert"
data-key-id="{{ key.id }}"
>
Regenerate
</button>
{% else %}
<span class="text-xs font-semibold text-gray-500">Revoked</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="mt-4 text-sm text-gray-600">No SSH keys uploaded yet.</p>
{% endif %}
</div>
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
}
return "";
}
function handleDownload(event) {
var button = event.currentTarget;
var url = button.getAttribute("data-download-url");
if (!url) {
return;
}
window.location.href = url;
}
function handleRegenerate(event) {
var button = event.currentTarget;
var keyId = button.getAttribute("data-key-id");
if (!keyId) {
return;
}
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": csrf,
},
})
.then(function (response) {
if (!response.ok) {
throw new Error("Certificate regeneration failed.");
}
window.location.href = "/api/v1/keys/" + keyId + "/certificate";
})
.catch(function (err) {
window.alert(err.message);
});
}
var downloadButtons = document.querySelectorAll("[data-download-url]");
for (var i = 0; i < downloadButtons.length; i += 1) {
downloadButtons[i].addEventListener("click", handleDownload);
}
var buttons = document.querySelectorAll(".js-regenerate-cert");
for (var j = 0; j < buttons.length; j += 1) {
buttons[j].addEventListener("click", handleRegenerate);
}
})();
</script>
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h2 class="text-base font-semibold tracking-tight text-gray-900">Data erasure request</h2>
<p class="mt-2 text-sm text-gray-600">
@@ -81,6 +227,7 @@
{% if not erasure_request or erasure_request.status != "pending" %}
<form method="post" class="mt-4 space-y-3">
{% csrf_token %}
<input type="hidden" name="form_type" value="erasure">
<div>
<label for="{{ erasure_form.reason.id_for_label }}" class="block text-sm font-medium text-gray-700">
Reason for request

View File

@@ -2,9 +2,14 @@ from django.conf import settings
from django.contrib.auth import logout
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.shortcuts import redirect, render
from .forms import ErasureRequestForm
from apps.keys.certificates import issue_certificate_for_key
from apps.keys.models import SSHKey
from .forms import ErasureRequestForm, SSHKeyForm
from .models import ErasureRequest
@@ -13,25 +18,55 @@ def profile(request):
erasure_request = (
ErasureRequest.objects.filter(user=request.user).order_by("-requested_at").first()
)
can_add_key = request.user.has_perm("keys.add_sshkey")
if request.method == "POST":
form = ErasureRequestForm(request.POST)
if form.is_valid():
form_type = request.POST.get("form_type")
if form_type == "ssh_key":
erasure_form = ErasureRequestForm()
key_form = SSHKeyForm(request.POST)
if key_form.is_valid():
if not can_add_key:
key_form.add_error(None, "You do not have permission to add SSH keys.")
else:
name = key_form.cleaned_data["name"].strip()
public_key = key_form.cleaned_data["public_key"].strip()
key = SSHKey(user=request.user, name=name)
try:
key.set_public_key(public_key)
key.save()
issue_certificate_for_key(key, created_by=request.user)
return redirect("accounts:profile")
except ValidationError as exc:
key_form.add_error("public_key", str(exc))
except IntegrityError:
key_form.add_error("public_key", "Key already exists.")
except Exception:
key_form.add_error(None, "Certificate issuance failed.")
else:
key_form = SSHKeyForm()
erasure_form = ErasureRequestForm(request.POST)
if erasure_form.is_valid():
if erasure_request and erasure_request.status == ErasureRequest.Status.PENDING:
form.add_error(None, "You already have a pending erasure request.")
erasure_form.add_error(None, "You already have a pending erasure request.")
else:
ErasureRequest.objects.create(
user=request.user,
reason=form.cleaned_data["reason"].strip(),
reason=erasure_form.cleaned_data["reason"].strip(),
)
return redirect("accounts:profile")
else:
form = ErasureRequestForm()
erasure_form = ErasureRequestForm()
key_form = SSHKeyForm()
ssh_keys = SSHKey.objects.filter(user=request.user).order_by("-created_at")
context = {
"user": request.user,
"auth_mode": getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid"),
"erasure_request": erasure_request,
"erasure_form": form,
"erasure_form": erasure_form,
"key_form": key_form,
"ssh_keys": ssh_keys,
"can_add_key": can_add_key,
}
return render(request, "accounts/profile.html", context)

View File

@@ -8,7 +8,7 @@ except ImportError: # Fallback for older Unfold builds without guardian admin s
class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin):
pass
from .models import SSHKey
from .models import SSHCertificate, SSHCertificateAuthority, SSHKey
@admin.register(SSHKey)
@@ -17,3 +17,21 @@ class SSHKeyAdmin(GuardedModelAdmin):
list_filter = ("is_active", "key_type")
search_fields = ("name", "user__username", "user__email", "fingerprint")
ordering = ("-created_at",)
@admin.register(SSHCertificateAuthority)
class SSHCertificateAuthorityAdmin(admin.ModelAdmin):
list_display = ("name", "fingerprint", "is_active", "created_at", "revoked_at")
list_filter = ("is_active",)
search_fields = ("name", "fingerprint")
readonly_fields = ("created_at", "revoked_at", "fingerprint", "public_key", "private_key")
ordering = ("-created_at",)
@admin.register(SSHCertificate)
class SSHCertificateAdmin(admin.ModelAdmin):
list_display = ("id", "user", "key", "serial", "is_active", "valid_before", "created_at")
list_filter = ("is_active",)
search_fields = ("user__username", "user__email", "serial")
readonly_fields = ("created_at", "revoked_at", "certificate")
ordering = ("-created_at",)

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
import os
import re
import secrets
import subprocess
import tempfile
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from .models import SSHCertificate, SSHCertificateAuthority, SSHKey
from .utils import render_system_username
def get_active_ca(created_by=None) -> SSHCertificateAuthority:
ca = (
SSHCertificateAuthority.objects.filter(is_active=True, revoked_at__isnull=True)
.order_by("-created_at")
.first()
)
if not ca:
ca = SSHCertificateAuthority(created_by=created_by)
ca.ensure_material()
ca.save()
return ca
def issue_certificate_for_key(key: SSHKey, created_by=None) -> SSHCertificate:
if not key or not key.user_id:
raise ValueError("key must have a user")
ca = get_active_ca(created_by=created_by)
principal = render_system_username(key.user.username, key.user_id)
now = timezone.now()
valid_before = now + timedelta(days=settings.KEYWARDEN_USER_CERT_VALIDITY_DAYS)
serial = secrets.randbits(63)
safe_name = _sanitize_label(key.name or "key")
identity = f"keywarden-cert-{key.user_id}-{safe_name}-{key.id}"
cert_text = _sign_public_key(
ca_private_key=ca.private_key,
ca_public_key=ca.public_key,
public_key=key.public_key,
identity=identity,
principal=principal,
serial=serial,
validity_days=settings.KEYWARDEN_USER_CERT_VALIDITY_DAYS,
comment=identity,
)
cert, _ = SSHCertificate.objects.update_or_create(
key=key,
defaults={
"user": key.user,
"certificate": cert_text,
"serial": serial,
"principals": [principal],
"valid_after": now,
"valid_before": valid_before,
"revoked_at": None,
"is_active": True,
},
)
return cert
def revoke_certificate_for_key(key: SSHKey) -> None:
if not key:
return
try:
cert = key.certificate
except SSHCertificate.DoesNotExist:
return
cert.revoke()
cert.save(update_fields=["is_active", "revoked_at"])
def _sign_public_key(
ca_private_key: str,
ca_public_key: str,
public_key: str,
identity: str,
principal: str,
serial: int,
validity_days: int,
comment: str,
) -> str:
if not ca_private_key or not ca_public_key:
raise RuntimeError("CA material missing")
with tempfile.TemporaryDirectory() as tmpdir:
ca_path = os.path.join(tmpdir, "user_ca")
pubkey_path = os.path.join(tmpdir, "user.pub")
_write_file(ca_path, ca_private_key, 0o600)
_write_file(ca_path + ".pub", ca_public_key.strip() + "\n", 0o644)
pubkey_with_comment = _ensure_comment(public_key, comment)
_write_file(pubkey_path, pubkey_with_comment + "\n", 0o644)
cmd = [
"ssh-keygen",
"-s",
ca_path,
"-I",
identity,
"-n",
principal,
"-V",
f"+{validity_days}d",
"-z",
str(serial),
pubkey_path,
]
try:
result = subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError as exc:
raise RuntimeError("ssh-keygen not available") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
cert_path = pubkey_path
if cert_path.endswith(".pub"):
cert_path = cert_path[: -len(".pub")]
cert_path += "-cert.pub"
if not os.path.exists(cert_path):
stderr = result.stderr.decode("utf-8", "ignore")
raise RuntimeError(f"ssh-keygen output missing: {cert_path} {stderr}")
with open(cert_path, "r", encoding="utf-8") as handle:
return handle.read().strip()
def _ensure_comment(public_key: str, comment: str) -> str:
parts = (public_key or "").strip().split()
if len(parts) < 2:
return public_key.strip()
key_type, key_b64 = parts[0], parts[1]
if not comment:
return f"{key_type} {key_b64}"
return f"{key_type} {key_b64} {comment}"
def _sanitize_label(value: str) -> str:
cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "-", (value or "").strip())
cleaned = cleaned.strip("-_")
if cleaned:
return cleaned.lower()
return "key"
def _write_file(path: str, data: str, mode: int) -> None:
with open(path, "w", encoding="utf-8") as handle:
handle.write(data)
os.chmod(path, mode)

View File

@@ -0,0 +1,86 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("keys", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="SSHCertificateAuthority",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(default="Keywarden User SSH CA", max_length=128)),
("public_key", models.TextField(blank=True)),
("private_key", models.TextField(blank=True)),
("fingerprint", models.CharField(blank=True, max_length=128)),
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
("is_active", models.BooleanField(db_index=True, default=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="ssh_certificate_authorities",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "SSH certificate authority",
"verbose_name_plural": "SSH certificate authorities",
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="SSHCertificate",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("certificate", models.TextField()),
("serial", models.BigIntegerField()),
("principals", models.JSONField(blank=True, default=list)),
("valid_after", models.DateTimeField()),
("valid_before", models.DateTimeField()),
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
("is_active", models.BooleanField(db_index=True, default=True)),
(
"key",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="certificate",
to="keys.sshkey",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ssh_certificates",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "SSH certificate",
"verbose_name_plural": "SSH certificates",
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="sshcertificate",
index=models.Index(fields=["user", "is_active"], name="keys_cert_user_active_idx"),
),
migrations.AddIndex(
model_name="sshcertificate",
index=models.Index(fields=["valid_before"], name="keys_cert_valid_before_idx"),
),
]

View File

@@ -3,6 +3,9 @@ from __future__ import annotations
import base64
import binascii
import hashlib
import os
import subprocess
import tempfile
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -61,6 +64,107 @@ class SSHKey(models.Model):
def revoke(self) -> None:
self.is_active = False
self.revoked_at = timezone.now()
try:
cert = self.certificate
except SSHCertificate.DoesNotExist:
return
cert.revoke()
cert.save(update_fields=["is_active", "revoked_at"])
def __str__(self) -> str:
return f"{self.name} ({self.user_id})"
class SSHCertificateAuthority(models.Model):
name = models.CharField(max_length=128, default="Keywarden User SSH CA")
public_key = models.TextField(blank=True)
private_key = models.TextField(blank=True)
fingerprint = models.CharField(max_length=128, blank=True)
created_at = models.DateTimeField(default=timezone.now, editable=False)
revoked_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="ssh_certificate_authorities",
)
class Meta:
verbose_name = "SSH certificate authority"
verbose_name_plural = "SSH certificate authorities"
ordering = ["-created_at"]
def __str__(self) -> str:
status = "active" if self.is_active and not self.revoked_at else "revoked"
return f"{self.name} ({status})"
def revoke(self) -> None:
self.is_active = False
self.revoked_at = timezone.now()
def ensure_material(self) -> None:
if self.public_key and self.private_key:
if not self.fingerprint:
_, _, fingerprint = parse_public_key(self.public_key)
self.fingerprint = fingerprint
return
with tempfile.TemporaryDirectory() as tmpdir:
key_path = os.path.join(tmpdir, "keywarden_user_ca")
cmd = [
"ssh-keygen",
"-t",
"ed25519",
"-f",
key_path,
"-C",
self.name,
"-N",
"",
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError as exc:
raise RuntimeError("ssh-keygen not available") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
with open(key_path, "r", encoding="utf-8") as handle:
self.private_key = handle.read()
with open(key_path + ".pub", "r", encoding="utf-8") as handle:
self.public_key = handle.read().strip()
_, _, fingerprint = parse_public_key(self.public_key)
self.fingerprint = fingerprint
class SSHCertificate(models.Model):
key = models.OneToOneField(
SSHKey, on_delete=models.CASCADE, related_name="certificate"
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ssh_certificates"
)
certificate = models.TextField()
serial = models.BigIntegerField()
principals = models.JSONField(default=list, blank=True)
valid_after = models.DateTimeField()
valid_before = models.DateTimeField()
created_at = models.DateTimeField(default=timezone.now, editable=False)
revoked_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
class Meta:
verbose_name = "SSH certificate"
verbose_name_plural = "SSH certificates"
indexes = [
models.Index(fields=["user", "is_active"], name="keys_cert_user_active_idx"),
models.Index(fields=["valid_before"], name="keys_cert_valid_before_idx"),
]
ordering = ["-created_at"]
def revoke(self) -> None:
self.is_active = False
self.revoked_at = timezone.now()
def __str__(self) -> str:
return f"{self.user_id} ({self.serial})"

29
app/apps/keys/utils.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import annotations
import re
from django.conf import settings
MAX_USERNAME_LEN = 32
_SANITIZE_RE = re.compile(r"[^a-z0-9_-]")
def render_system_username(username: str, user_id: int) -> str:
template = settings.KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE
raw = template.replace("{{username}}", username or "")
raw = raw.replace("{{user_id}}", str(user_id))
cleaned = sanitize_username(raw)
if len(cleaned) > MAX_USERNAME_LEN:
cleaned = cleaned[:MAX_USERNAME_LEN]
if cleaned:
return cleaned
return f"kw_{user_id}"
def sanitize_username(raw: str) -> str:
raw = (raw or "").lower()
raw = _SANITIZE_RE.sub("_", raw)
raw = raw.strip("-_")
if raw.startswith("-"):
return "kw" + raw
return raw

View File

@@ -46,9 +46,35 @@
<div class="flex items-center justify-between">
<dt>Certificate</dt>
<dd class="font-medium text-gray-900">
<span class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2 py-1 text-xs font-semibold text-gray-500">
Download coming soon
</span>
{% if certificate_key_id %}
<div class="flex items-center gap-2">
<div class="inline-flex overflow-hidden rounded-md shadow-sm">
<button
type="button"
class="inline-flex items-center rounded-l-md bg-purple-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-purple-700"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-md border border-l-0 border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-200"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 js-regenerate-cert"
data-key-id="{{ certificate_key_id }}"
>
Regenerate
</button>
</div>
{% else %}
<span class="text-xs font-semibold text-gray-500">Upload a key to download</span>
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between">
@@ -93,4 +119,62 @@
</div>
</section>
</div>
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
}
return "";
}
function handleDownload(event) {
var button = event.currentTarget;
var url = button.getAttribute("data-download-url");
if (!url) {
return;
}
window.location.href = url;
}
function handleRegenerate(event) {
var button = event.currentTarget;
var keyId = button.getAttribute("data-key-id");
if (!keyId) {
return;
}
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": csrf,
},
})
.then(function (response) {
if (!response.ok) {
throw new Error("Certificate regeneration failed.");
}
window.location.href = "/api/v1/keys/" + keyId + "/certificate";
})
.catch(function (err) {
window.alert(err.message);
});
}
var downloadButtons = document.querySelectorAll("[data-download-url]");
for (var i = 0; i < downloadButtons.length; i += 1) {
downloadButtons[i].addEventListener("click", handleDownload);
}
var buttons = document.querySelectorAll(".js-regenerate-cert");
for (var j = 0; j < buttons.length; j += 1) {
buttons[j].addEventListener("click", handleRegenerate);
}
})();
</script>
{% endblock %}

View File

@@ -8,6 +8,7 @@ from django.utils import timezone
from guardian.shortcuts import get_objects_for_user, get_perms
from apps.access.models import AccessRequest
from apps.keys.models import SSHKey
from apps.servers.models import Server, ServerAccount
@@ -78,6 +79,9 @@ def detail(request, server_id: int):
)
account = ServerAccount.objects.filter(server=server, user=request.user).first()
active_key = (
SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first()
)
context = {
"server": server,
"expires_at": access.expires_at if access else None,
@@ -85,5 +89,6 @@ def detail(request, server_id: int):
"account_present": account.is_present if account else None,
"account_synced_at": account.last_synced_at if account else None,
"system_username": account.system_username if account else None,
"certificate_key_id": active_key.id if active_key else None,
}
return render(request, "servers/detail.html", context)

View File

@@ -18,7 +18,9 @@ from pydantic import Field
from guardian.shortcuts import get_users_with_perms
from apps.core.rbac import require_perms
from apps.keys.certificates import get_active_ca
from apps.keys.models import SSHKey
from apps.keys.utils import render_system_username
from apps.servers.models import (
AgentCertificateAuthority,
EnrollmentToken,
@@ -46,7 +48,8 @@ class AccountAccessOut(Schema):
user_id: int
username: str
email: str
keys: List[AccountKeyOut]
system_username: str
keys: List[AccountKeyOut] = Field(default_factory=list)
class AccountSyncIn(Schema):
@@ -215,20 +218,30 @@ def build_router() -> Router:
"""
server = _get_server_or_404(server_id)
users = _resolve_access_users(server)
key_map = _key_map_for_users(users)
return [
AccountAccessOut(
user_id=user.id,
username=user.username,
email=user.email or "",
keys=[
AccountKeyOut(public_key=key.public_key, fingerprint=key.fingerprint)
for key in key_map.get(user.id, [])
],
system_username=render_system_username(user.username, user.id),
keys=[],
)
for user in users
]
@router.get("/servers/{server_id}/ssh-ca", auth=None)
@csrf_exempt
def ssh_ca(request: HttpRequest, server_id: int):
"""Return the active SSH user CA public key for agents.
Auth: mTLS expected at the edge (no session/JWT).
"""
_ = _get_server_or_404(server_id)
ca = get_active_ca()
if not ca.public_key:
raise HttpError(404, "SSH CA not configured")
return {"public_key": ca.public_key, "fingerprint": ca.fingerprint}
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=None)
@csrf_exempt
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):

View File

@@ -3,9 +3,11 @@ from __future__ import annotations
from typing import List, Optional
from django.contrib.auth import get_user_model
import hashlib
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.http import HttpRequest
from django.db import IntegrityError, transaction
from django.http import HttpRequest, HttpResponse
from django.utils import timezone
from guardian.shortcuts import get_objects_for_user
from ninja import Query, Router, Schema
@@ -13,7 +15,8 @@ from ninja.errors import HttpError
from pydantic import Field
from apps.core.rbac import require_authenticated
from apps.keys.models import SSHKey
from apps.keys.certificates import issue_certificate_for_key, revoke_certificate_for_key
from apps.keys.models import SSHCertificate, SSHKey
class KeyCreateIn(Schema):
@@ -39,6 +42,14 @@ class KeyOut(Schema):
revoked_at: Optional[str] = None
class CertificateOut(Schema):
key_id: int
serial: int
valid_after: str
valid_before: str
principals: List[str]
class KeysQuery(Schema):
limit: int = Field(default=50, ge=1, le=200)
offset: int = Field(default=0, ge=0)
@@ -59,6 +70,19 @@ def _key_to_out(key: SSHKey) -> KeyOut:
)
def _ensure_certificate(key: SSHKey, request_user) -> SSHCertificate:
if not key.is_active:
raise HttpError(409, "Key is revoked")
now = timezone.now()
try:
cert = key.certificate
except SSHCertificate.DoesNotExist:
return issue_certificate_for_key(key, created_by=request_user)
if not cert.is_active or cert.valid_before <= now:
return issue_certificate_for_key(key, created_by=request_user)
return cert
def _has_global_perm(request: HttpRequest, perm: str) -> bool:
user = request.user
return bool(user and user.has_perm(perm))
@@ -131,9 +155,13 @@ def build_router() -> Router:
except ValidationError as exc:
raise HttpError(422, {"public_key": [str(exc)]})
try:
with transaction.atomic():
key.save()
issue_certificate_for_key(key, created_by=request.user)
except IntegrityError:
raise HttpError(422, {"public_key": ["Key already exists."]})
except Exception as exc:
raise HttpError(500, {"detail": f"Certificate issuance failed: {exc}"})
return _key_to_out(key)
@router.get("/{key_id}", response=KeyOut)
@@ -153,6 +181,64 @@ def build_router() -> Router:
raise HttpError(403, "Forbidden")
return _key_to_out(key)
@router.post("/{key_id}/certificate", response=CertificateOut)
def issue_certificate(request: HttpRequest, key_id: int):
"""Issue or re-issue an SSH certificate for a key.
Auth: required.
Permissions: requires `keys.view_sshkey` on the object.
Rationale: allows users to download a fresh certificate as needed.
"""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not request.user.has_perm("keys.view_sshkey", key):
raise HttpError(403, "Forbidden")
cert = issue_certificate_for_key(key, created_by=request.user)
return CertificateOut(
key_id=key.id,
serial=cert.serial,
valid_after=cert.valid_after.isoformat(),
valid_before=cert.valid_before.isoformat(),
principals=list(cert.principals or []),
)
@router.get("/{key_id}/certificate")
def download_certificate(request: HttpRequest, key_id: int):
"""Download the SSH certificate for a key."""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not request.user.has_perm("keys.view_sshkey", key):
raise HttpError(403, "Forbidden")
cert = _ensure_certificate(key, request.user)
filename = f"keywarden-{key.user_id}-{key.id}-cert.pub"
response = HttpResponse(cert.certificate, content_type="text/plain")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
@router.get("/{key_id}/certificate.sha256")
def download_certificate_hash(request: HttpRequest, key_id: int):
"""Download the SSH certificate hash for a key."""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not request.user.has_perm("keys.view_sshkey", key):
raise HttpError(403, "Forbidden")
cert = _ensure_certificate(key, request.user)
filename = f"keywarden-{key.user_id}-{key.id}-cert.pub"
digest = hashlib.sha256(cert.certificate.encode("utf-8")).hexdigest()
payload = f"{digest} {filename}\n"
response = HttpResponse(payload, content_type="text/plain")
response["Content-Disposition"] = f'attachment; filename="{filename}.sha256"'
return response
@router.patch("/{key_id}", response=KeyOut)
def update_key(request: HttpRequest, key_id: int, payload: KeyUpdateIn):
"""Update key name or active state.
@@ -179,8 +265,13 @@ def build_router() -> Router:
key.is_active = payload.is_active
if payload.is_active:
key.revoked_at = None
try:
issue_certificate_for_key(key, created_by=request.user)
except Exception as exc:
raise HttpError(500, {"detail": f"Certificate issuance failed: {exc}"})
else:
key.revoked_at = timezone.now()
revoke_certificate_for_key(key)
key.save()
return _key_to_out(key)
@@ -204,6 +295,7 @@ def build_router() -> Router:
key.is_active = False
key.revoked_at = timezone.now()
key.save(update_fields=["is_active", "revoked_at"])
revoke_certificate_for_key(key)
return 204, None
return router

View File

@@ -97,6 +97,10 @@ SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
KEYWARDEN_AGENT_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_AGENT_CERT_VALIDITY_DAYS", "90"))
KEYWARDEN_USER_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_USER_CERT_VALIDITY_DAYS", "30"))
KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE = os.getenv(
"KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE", "{{username}}_{{user_id}}"
)
CELERY_BROKER_URL = os.getenv("KEYWARDEN_CELERY_BROKER_URL", REDIS_URL)
CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL)

File diff suppressed because one or more lines are too long

View File

@@ -718,3 +718,17 @@ const renderCharts = () => {
changeDarkModeSettings();
});
};
function getCurrentTab() {
const fragment = window.location.hash?.replace('#', '');
if (!fragment) {
return null
}
if (!document.getElementById(`${fragment}-group`)) {
return null;
}
return fragment
}