diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index e8c3552..cf00115 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -36,6 +36,7 @@ func RegisterRoutes(r *gin.Engine) { apiGroup.GET("/keys", listKeys) apiGroup.POST("/keys", createKey) apiGroup.GET("/keys/:id", getKey) + apiGroup.GET("/keys/:id/private-key", getPrivateKey) apiGroup.DELETE("/keys/:id", deleteKey) apiGroup.POST("/keys/:id/assign", assignKey) apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment) @@ -173,15 +174,16 @@ func listKeys(c *gin.Context) { func createKey(c *gin.Context) { var body struct { - Label string `json:"label" binding:"required"` - PublicKey string `json:"public_key" binding:"required"` + Label string `json:"label" binding:"required"` + PublicKey string `json:"public_key" binding:"required"` + PrivateKey string `json:"private_key"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "") + key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -189,6 +191,16 @@ func createKey(c *gin.Context) { c.JSON(http.StatusCreated, key) } +func getPrivateKey(c *gin.Context) { + id := c.Param("id") + plaintext, err := services.GetPrivateKey(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"private_key": plaintext}) +} + func getKey(c *gin.Context) { id := c.Param("id") key, err := services.GetKey(id) diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index dd9da66..691fae4 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -54,7 +54,7 @@ func (s *keyManagerServer) UploadGeneratedKey(ctx context.Context, req *pb.Uploa return nil, status.Errorf(codes.Unauthenticated, "invalid agent token") } - key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID) + key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, "") if err != nil { return nil, status.Errorf(codes.Internal, "failed to store key: %v", err) } diff --git a/server/internal/models/key.go b/server/internal/models/key.go index ad139f9..42c6121 100644 --- a/server/internal/models/key.go +++ b/server/internal/models/key.go @@ -8,11 +8,13 @@ import ( type Key struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - KeyID string `bson:"key_id" json:"key_id"` - Label string `bson:"label" json:"label"` - PublicKey string `bson:"public_key" json:"public_key"` - Fingerprint string `bson:"fingerprint" json:"fingerprint"` - Source string `bson:"source" json:"source"` // uploaded | generated - GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` + KeyID string `bson:"key_id" json:"key_id"` + Label string `bson:"label" json:"label"` + PublicKey string `bson:"public_key" json:"public_key"` + Fingerprint string `bson:"fingerprint" json:"fingerprint"` + Source string `bson:"source" json:"source"` // uploaded | generated + GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"` + PrivateKeyEncrypted string `bson:"private_key_enc,omitempty" json:"-"` + HasPrivateKey bool `bson:"-" json:"has_private_key"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` } diff --git a/server/internal/services/crypto.go b/server/internal/services/crypto.go new file mode 100644 index 0000000..835bf6d --- /dev/null +++ b/server/internal/services/crypto.go @@ -0,0 +1,72 @@ +package services + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "os" +) + +func encryptionKey() ([]byte, error) { + raw := os.Getenv("KEY_ENCRYPTION_KEY") + if raw == "" { + return nil, fmt.Errorf("KEY_ENCRYPTION_KEY is not set") + } + key, err := hex.DecodeString(raw) + if err != nil || len(key) != 32 { + return nil, fmt.Errorf("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)") + } + return key, nil +} + +func encryptPrivateKey(plaintext string) (string, error) { + key, err := encryptionKey() + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return hex.EncodeToString(sealed), nil +} + +func decryptPrivateKey(ciphertextHex string) (string, error) { + key, err := encryptionKey() + if err != nil { + return "", err + } + data, err := hex.DecodeString(ciphertextHex) + if err != nil { + return "", fmt.Errorf("invalid ciphertext encoding") + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil) + if err != nil { + return "", fmt.Errorf("decryption failed") + } + return string(plaintext), nil +} diff --git a/server/internal/services/keys.go b/server/internal/services/keys.go index 289d182..af40c35 100644 --- a/server/internal/services/keys.go +++ b/server/internal/services/keys.go @@ -31,7 +31,11 @@ func computeFingerprint(pubKey string) string { return "MD5:" + strings.Join(pairs, ":") } -func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Key, error) { +func setKeyMeta(k *models.Key) { + k.HasPrivateKey = k.PrivateKeyEncrypted != "" +} + +func CreateKey(label, publicKey, source, generatedByServerID, privateKey string) (*models.Key, error) { key := &models.Key{ KeyID: uuid.NewString(), Label: label, @@ -41,6 +45,13 @@ func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Ke GeneratedByServerID: generatedByServerID, CreatedAt: time.Now(), } + if privateKey != "" { + enc, err := encryptPrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("encrypt private key: %w", err) + } + key.PrivateKeyEncrypted = enc + } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -48,6 +59,7 @@ func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Ke if err != nil { return nil, err } + setKeyMeta(key) return key, nil } @@ -60,9 +72,24 @@ func GetKey(keyID string) (*models.Key, error) { if err != nil { return nil, err } + setKeyMeta(&key) return &key, nil } +func GetPrivateKey(keyID string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var key models.Key + if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil { + return "", err + } + if key.PrivateKeyEncrypted == "" { + return "", fmt.Errorf("no private key stored for this key") + } + return decryptPrivateKey(key.PrivateKeyEncrypted) +} + type KeyWithCount struct { models.Key `bson:",inline"` AssignedCount int `bson:"-" json:"assigned_count"` @@ -85,6 +112,7 @@ func ListKeys() ([]KeyWithCount, error) { result := make([]KeyWithCount, 0, len(keys)) for _, k := range keys { + setKeyMeta(&k) count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{ "key_id": k.KeyID, "revoked_at": nil, @@ -219,6 +247,7 @@ func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, erro if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err != nil { continue } + setKeyMeta(&key) result = append(result, AssignmentWithKey{Assignment: a, Key: &key}) } return result, nil diff --git a/web/app/keys/[id]/page.tsx b/web/app/keys/[id]/page.tsx index 3b1230b..b954b0d 100644 --- a/web/app/keys/[id]/page.tsx +++ b/web/app/keys/[id]/page.tsx @@ -89,6 +89,97 @@ function AssignModal({ ); } +function PrivateKeyCard({ keyId }: { keyId: string }) { + const [revealed, setRevealed] = useState(false); + const [privateKey, setPrivateKey] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + async function reveal() { + setLoading(true); + setError(null); + try { + const res = await api.getPrivateKey(keyId); + setPrivateKey(res.private_key); + setRevealed(true); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + } + + function download() { + if (!privateKey) return; + const blob = new Blob([privateKey], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${keyId}.pem`; + a.click(); + URL.revokeObjectURL(url); + } + + async function copy() { + if (!privateKey) return; + await navigator.clipboard.writeText(privateKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + + + Private Key + {revealed && ( +
+ + +
+ )} +
+ + {error && ( +
+ {error} +
+ )} + + {!revealed ? ( +
+

+ Stored encrypted (AES-256-GCM). Click to decrypt and display. +

+ +
+ ) : ( +
+
+            {privateKey}
+          
+
+ )} +
+ ); +} + export default function KeyDetailPage() { const params = useParams(); const router = useRouter(); @@ -252,6 +343,8 @@ export default function KeyDetailPage() { + + {key.has_private_key && }
diff --git a/web/app/keys/page.tsx b/web/app/keys/page.tsx index 1238035..cbfe106 100644 --- a/web/app/keys/page.tsx +++ b/web/app/keys/page.tsx @@ -11,9 +11,10 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) { const queryClient = useQueryClient(); const [label, setLabel] = useState(""); const [publicKey, setPublicKey] = useState(""); + const [privateKey, setPrivateKey] = useState(""); const { mutate: upload, isPending, error } = useMutation({ - mutationFn: () => api.uploadKey(label.trim(), publicKey.trim()), + mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["keys"] }); onClose(); @@ -52,7 +53,20 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) { value={publicKey} onChange={(e) => setPublicKey(e.target.value)} placeholder="ssh-ed25519 AAAA..." - rows={4} + rows={3} + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none" + /> +
+
+ +