Compare commits
1 Commits
agent/v1.0.4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 407a610cfb |
@@ -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)
|
||||
@@ -175,13 +176,14 @@ func createKey(c *gin.Context) {
|
||||
var body struct {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -14,5 +14,7 @@ type Key struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -89,6 +89,97 @@ function AssignModal({
|
||||
);
|
||||
}
|
||||
|
||||
function PrivateKeyCard({ keyId }: { keyId: string }) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [privateKey, setPrivateKey] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Private Key</CardTitle>
|
||||
{revealed && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copy}
|
||||
className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
{copied ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={download}
|
||||
className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
Download .pem
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!revealed ? (
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<p className="text-center text-xs text-text-tertiary">
|
||||
Stored encrypted (AES-256-GCM). Click to decrypt and display.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" loading={loading} onClick={reveal}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.964-7.178z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Reveal Private Key
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-[#0a0c14] p-3">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
|
||||
{privateKey}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KeyDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
@@ -252,6 +343,8 @@ export default function KeyDetailPage() {
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{key.has_private_key && <PrivateKeyCard keyId={keyId} />}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
|
||||
+16
-2
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Private Key{" "}
|
||||
<span className="text-text-tertiary font-normal">(optional — stored AES-256-GCM encrypted)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+7
-2
@@ -20,6 +20,7 @@ export interface Key {
|
||||
fingerprint: string;
|
||||
source: KeySource;
|
||||
generated_by_server_id?: string;
|
||||
has_private_key: boolean;
|
||||
created_at: string;
|
||||
assigned_count?: number;
|
||||
}
|
||||
@@ -124,13 +125,17 @@ export const api = {
|
||||
return request<KeyWithAssignments>(`/keys/${keyId}`);
|
||||
},
|
||||
|
||||
uploadKey(label: string, public_key: string): Promise<Key> {
|
||||
uploadKey(label: string, public_key: string, private_key?: string): Promise<Key> {
|
||||
return request<Key>("/keys", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ label, public_key }),
|
||||
body: JSON.stringify({ label, public_key, private_key: private_key || undefined }),
|
||||
});
|
||||
},
|
||||
|
||||
getPrivateKey(keyId: string): Promise<{ private_key: string }> {
|
||||
return request<{ private_key: string }>(`/keys/${keyId}/private-key`);
|
||||
},
|
||||
|
||||
deleteKey(keyId: string): Promise<void> {
|
||||
return request<void>(`/keys/${keyId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user