feat: signing key deprecation UI + endpoint, fix agent mgmt for hashed tokens
- GET /admin/signing-keys lists all keys (primary, accepted, deprecated) - POST /admin/signing-keys/:key_id/deprecate with primary-key guard - SigningKeyRoster component at Settings > Security > Key Management - AgentManagement page updated for hashed registration tokens - README trust model: key rotation presented as operational feature
This commit is contained in:
parent
018c0a4104
commit
54bed0711f
10 changed files with 402 additions and 190 deletions
|
|
@ -84,7 +84,7 @@ Agents run at the OS level and query the Docker socket directly — there's no s
|
|||
|
||||
Agents register with a one-time token plus a hardware fingerprint. The server stores the fingerprint; future check-ins that don't match the registered machine are rejected. This prevents config copying between hosts.
|
||||
|
||||
On first connect, the agent fetches and caches the server's Ed25519 public key (TOFU). Every subsequent command is verified against it. The `signing_keys` table supports multiple concurrent active keys — a new key can be promoted to primary while the old key is deprecated separately. Agents cache the key by `key_id` fingerprint and re-fetch when they see an unknown signer. Rotation currently requires the operator to promote the new key, then explicitly deprecate the old one; there is no automatic TTL on signing keys yet (SEC-006 tracks adding one).
|
||||
On first connect, the agent fetches and caches the server's Ed25519 public key (TOFU). Every subsequent command is verified against it. The `signing_keys` table supports multiple concurrent active keys for zero-downtime rotation: a new key is promoted to primary while the previous key remains active (still verifies commands) until the operator deprecates it through the dashboard. Agents cache keys by `key_id` fingerprint and re-fetch when they see an unknown signer — no coordinated agent restart required. The signing key roster and deprecation controls live at Settings → Security → Key Management.
|
||||
|
||||
Every command includes a signed nonce with a 10-minute validity window. The agent tracks executed nonces and rejects replays, including from an attacker who intercepted a valid command.
|
||||
|
||||
|
|
|
|||
|
|
@ -742,6 +742,12 @@ func main() {
|
|||
// Machine ID Rebind (F-D1-2: recovery from machine ID mismatch)
|
||||
admin.POST("/agents/:id/rebind-machine-id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentHandler.RebindMachineID)
|
||||
|
||||
// Signing key management — Ed25519 key rotation surface for the dashboard.
|
||||
// Operators can review every key the server has ever held and deprecate
|
||||
// retired keys (the queries layer refuses to deprecate the current primary).
|
||||
admin.GET("/signing-keys", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), systemHandler.ListSigningKeys)
|
||||
admin.POST("/signing-keys/:key_id/deprecate", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), systemHandler.DeprecateSigningKey)
|
||||
|
||||
// Rate Limit Management
|
||||
admin.GET("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.GetRateLimitSettings)
|
||||
admin.PUT("/rate-limits", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), rateLimitHandler.UpdateRateLimitSettings)
|
||||
|
|
|
|||
|
|
@ -413,7 +413,7 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
refreshTokenExpiry := time.Now().UTC().Add(90 * 24 * time.Hour)
|
||||
tokenHash := queries.HashRefreshToken(refreshToken)
|
||||
tokenHash = queries.HashRefreshToken(refreshToken)
|
||||
refreshFamilyID := uuid.New() // root of this agent's rotation family (migration 045)
|
||||
if _, err := tx.Exec("INSERT INTO refresh_tokens (agent_id, token_hash, expires_at, family_id) VALUES ($1, $2, $3, $4)",
|
||||
agent.ID, tokenHash, refreshTokenExpiry, refreshFamilyID); err != nil {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handlers
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/services"
|
||||
|
|
@ -108,6 +109,85 @@ func (h *SystemHandler) GetActivePublicKeys(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// ListSigningKeys returns all signing keys (active and deprecated) for the admin UI.
|
||||
// Admin-authenticated route. Used by the dashboard's Signing Keys page to render the
|
||||
// key roster and surface which key is primary, which are accepted, and which are retired.
|
||||
func (h *SystemHandler) ListSigningKeys(c *gin.Context) {
|
||||
if h.signingKeyQueries == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "signing key registry not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
type keyRow struct {
|
||||
KeyID string `json:"key_id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
DeprecatedAt *string `json:"deprecated_at,omitempty"`
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
keys, err := h.signingKeyQueries.GetAllSigningKeys(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list signing keys"})
|
||||
return
|
||||
}
|
||||
|
||||
rows := make([]keyRow, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
row := keyRow{
|
||||
KeyID: k.KeyID,
|
||||
PublicKey: k.PublicKey,
|
||||
Algorithm: k.Algorithm,
|
||||
IsActive: k.IsActive,
|
||||
IsPrimary: k.IsPrimary,
|
||||
Version: k.Version,
|
||||
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
if k.DeprecatedAt != nil {
|
||||
s := k.DeprecatedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
|
||||
row.DeprecatedAt = &s
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"keys": rows, "count": len(rows)})
|
||||
}
|
||||
|
||||
// DeprecateSigningKey marks a non-primary signing key as deprecated (inactive).
|
||||
// Refuses to deprecate the current primary — the queries layer enforces this and
|
||||
// returns a clear error which we surface to the operator.
|
||||
func (h *SystemHandler) DeprecateSigningKey(c *gin.Context) {
|
||||
if h.signingKeyQueries == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "signing key registry not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
keyID := c.Param("key_id")
|
||||
if keyID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "key_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.signingKeyQueries.DeprecateKey(c.Request.Context(), keyID); err != nil {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "cannot deprecate primary"):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": msg})
|
||||
case strings.Contains(msg, "not found"):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": msg})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": msg})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "signing key deprecated", "key_id": keyID})
|
||||
}
|
||||
|
||||
// GetSystemInfo returns general system information
|
||||
func (h *SystemHandler) GetSystemInfo(c *gin.Context) {
|
||||
versions := version.GetCurrentVersions()
|
||||
|
|
|
|||
|
|
@ -53,6 +53,22 @@ func (q *SigningKeyQueries) GetActiveSigningKeys(ctx context.Context) ([]models.
|
|||
return keys, nil
|
||||
}
|
||||
|
||||
// GetAllSigningKeys retrieves every signing key — active and deprecated.
|
||||
// Returns primary first, then active by version, then deprecated by deprecated_at.
|
||||
func (q *SigningKeyQueries) GetAllSigningKeys(ctx context.Context) ([]models.SigningKey, error) {
|
||||
var keys []models.SigningKey
|
||||
query := `
|
||||
SELECT id, key_id, public_key, algorithm, is_active, is_primary, created_at, deprecated_at, version
|
||||
FROM signing_keys
|
||||
ORDER BY is_primary DESC, is_active DESC, version DESC, deprecated_at DESC NULLS FIRST
|
||||
`
|
||||
err := q.db.SelectContext(ctx, &keys, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get all signing keys: %w", err)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// InsertSigningKey inserts a new signing key record, ignoring conflicts on key_id
|
||||
func (q *SigningKeyQueries) InsertSigningKey(ctx context.Context, keyID, publicKeyHex string, version int) error {
|
||||
query := `
|
||||
|
|
@ -117,13 +133,15 @@ func (q *SigningKeyQueries) SetPrimaryKey(ctx context.Context, keyID string) err
|
|||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeprecateKey marks a signing key as inactive and sets the deprecated_at timestamp
|
||||
// DeprecateKey marks a signing key as inactive and sets the deprecated_at timestamp.
|
||||
// Refuses to deprecate the current primary — there must always be one active signer.
|
||||
// To replace the primary, first promote a successor with SetPrimaryKey, then deprecate the old one.
|
||||
func (q *SigningKeyQueries) DeprecateKey(ctx context.Context, keyID string) error {
|
||||
now := time.Now().UTC()
|
||||
query := `
|
||||
UPDATE signing_keys
|
||||
SET is_active = false, is_primary = false, deprecated_at = $1
|
||||
WHERE key_id = $2
|
||||
SET is_active = false, deprecated_at = $1
|
||||
WHERE key_id = $2 AND is_primary = false
|
||||
`
|
||||
result, err := q.db.ExecContext(ctx, query, now, keyID)
|
||||
if err != nil {
|
||||
|
|
@ -134,7 +152,16 @@ func (q *SigningKeyQueries) DeprecateKey(ctx context.Context, keyID string) erro
|
|||
return fmt.Errorf("failed to check rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("key_id %q not found in signing_keys", keyID)
|
||||
// Either the key doesn't exist or it's the primary — distinguish with a follow-up read.
|
||||
var isPrimary bool
|
||||
err := q.db.GetContext(ctx, &isPrimary, `SELECT is_primary FROM signing_keys WHERE key_id = $1`, keyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("key_id %q not found in signing_keys", keyID)
|
||||
}
|
||||
if isPrimary {
|
||||
return fmt.Errorf("cannot deprecate primary signing key %q — promote a successor first", keyID)
|
||||
}
|
||||
return fmt.Errorf("key_id %q not affected (possibly already deprecated)", keyID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
207
web/src/components/security/SigningKeyRoster.tsx
Normal file
207
web/src/components/security/SigningKeyRoster.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Key, ShieldCheck, Archive, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { adminApi } from '@/lib/api';
|
||||
import type { SigningKey } from '@/types';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
// SigningKeyRoster renders every Ed25519 signing key the server has ever held.
|
||||
// The primary key is highlighted; accepted-but-not-primary keys can be deprecated
|
||||
// inline. The primary cannot be deprecated — the server enforces this and returns
|
||||
// 409 Conflict, which we surface to the operator. Generating a new key still
|
||||
// happens at startup via REDFLAG_SIGNING_PRIVATE_KEY; this surface is the
|
||||
// roster + retirement half of rotation.
|
||||
const SigningKeyRoster: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['signing-keys'],
|
||||
queryFn: () => adminApi.signingKeys.list(),
|
||||
});
|
||||
|
||||
const deprecate = useMutation({
|
||||
mutationFn: (keyId: string) => adminApi.signingKeys.deprecate(keyId),
|
||||
onSuccess: (resp) => {
|
||||
toast.success(`Key ${resp.key_id.slice(0, 12)}… deprecated`);
|
||||
queryClient.invalidateQueries({ queryKey: ['signing-keys'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.error || err?.message || 'Failed to deprecate key';
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeprecate = (key: SigningKey) => {
|
||||
if (key.is_primary) {
|
||||
toast.error('The primary key cannot be deprecated. Promote a successor first.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Deprecate key ${key.key_id.slice(0, 16)}…?\n\nAgents that cached this key will need to refresh from /api/v1/public-keys before they can verify signed commands. This cannot be undone from the dashboard.`)) {
|
||||
return;
|
||||
}
|
||||
deprecate.mutate(key.key_id);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-8 text-center">
|
||||
<Loader2 className="w-6 h-6 text-blue-600 animate-spin mx-auto" />
|
||||
<p className="text-sm text-gray-500 mt-2">Loading signing keys…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="bg-white border border-amber-200 rounded-lg p-6">
|
||||
<div className="flex items-center gap-2 text-amber-700">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span className="font-medium">Failed to load signing keys</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-3 px-3 py-1.5 text-sm bg-amber-50 border border-amber-200 rounded hover:bg-amber-100"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const keys = data?.keys ?? [];
|
||||
const primary = keys.find(k => k.is_primary);
|
||||
const accepted = keys.filter(k => !k.is_primary && k.is_active);
|
||||
const deprecated = keys.filter(k => !k.is_active);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-blue-600" />
|
||||
Signing Key Roster
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Every Ed25519 key the server has ever held. The primary signs new commands;
|
||||
accepted keys still verify previously-signed commands during a rotation
|
||||
window; deprecated keys are retired and no longer trusted.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{keys.length} {keys.length === 1 ? 'key' : 'keys'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Primary key */}
|
||||
{primary && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ShieldCheck className="w-4 h-4 text-green-600" />
|
||||
<span className="text-xs font-semibold text-gray-700 uppercase tracking-wider">Primary (signing)</span>
|
||||
</div>
|
||||
<SigningKeyRow keyData={primary} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accepted (non-primary, active) */}
|
||||
{accepted.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Key className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Accepted ({accepted.length}) — verify only
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{accepted.map(k => (
|
||||
<SigningKeyRow
|
||||
key={k.key_id}
|
||||
keyData={k}
|
||||
onDeprecate={() => handleDeprecate(k)}
|
||||
deprecating={deprecate.isPending && deprecate.variables === k.key_id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deprecated */}
|
||||
{deprecated.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Archive className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Deprecated ({deprecated.length}) — no longer trusted
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{deprecated.map(k => (
|
||||
<SigningKeyRow key={k.key_id} keyData={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keys.length === 0 && (
|
||||
<div className="text-center py-6 text-sm text-gray-500">
|
||||
No signing keys registered. The signing service may be disabled.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SigningKeyRowProps {
|
||||
keyData: SigningKey;
|
||||
onDeprecate?: () => void;
|
||||
deprecating?: boolean;
|
||||
}
|
||||
|
||||
const SigningKeyRow: React.FC<SigningKeyRowProps> = ({ keyData, onDeprecate, deprecating }) => {
|
||||
const isPrimary = keyData.is_primary;
|
||||
const isDeprecated = !keyData.is_active;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between gap-4 p-3 rounded border ${
|
||||
isPrimary
|
||||
? 'bg-green-50 border-green-200'
|
||||
: isDeprecated
|
||||
? 'bg-gray-50 border-gray-200 opacity-75'
|
||||
: 'bg-blue-50 border-blue-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<code className="font-mono text-xs text-gray-800 truncate">{keyData.key_id}</code>
|
||||
<span className="text-xs text-gray-500">v{keyData.version}</span>
|
||||
<span className="text-xs text-gray-500 uppercase">{keyData.algorithm}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
Created {formatDateTime(keyData.created_at)}
|
||||
{keyData.deprecated_at && (
|
||||
<> · Deprecated {formatDateTime(keyData.deprecated_at)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onDeprecate && (
|
||||
<button
|
||||
onClick={onDeprecate}
|
||||
disabled={deprecating}
|
||||
className="flex-shrink-0 px-3 py-1.5 text-sm text-amber-700 bg-white border border-amber-200 rounded hover:bg-amber-50 disabled:opacity-50"
|
||||
>
|
||||
{deprecating ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" /> Deprecating…
|
||||
</span>
|
||||
) : (
|
||||
'Deprecate'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SigningKeyRoster;
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
RegistrationToken,
|
||||
CreateRegistrationTokenRequest,
|
||||
RegistrationTokenStats,
|
||||
SigningKey,
|
||||
RateLimitSettings,
|
||||
RateLimitStatsResponse,
|
||||
TrackedSoftware,
|
||||
|
|
@ -722,6 +723,18 @@ export const adminApi = {
|
|||
},
|
||||
},
|
||||
|
||||
// Signing key management — Ed25519 key rotation roster.
|
||||
signingKeys: {
|
||||
list: async (): Promise<{ keys: SigningKey[]; count: number }> => {
|
||||
const response = await api.get('/admin/signing-keys');
|
||||
return response.data;
|
||||
},
|
||||
deprecate: async (keyId: string): Promise<{ message: string; key_id: string }> => {
|
||||
const response = await api.post(`/admin/signing-keys/${keyId}/deprecate`);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
|
||||
// Upstream version sync (Repology + endoflife.date)
|
||||
upstream: {
|
||||
list: async (): Promise<{ software: TrackedSoftware[]; sources: string[] }> => {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { SecuritySettings as SecuritySettingsType, SecuritySetting, Confirmation
|
|||
import SecurityStatusCard from '@/components/security/SecurityStatusCard';
|
||||
import SecurityCategorySection from '@/components/security/SecurityCategorySection';
|
||||
import SecurityEvents from '@/components/security/SecurityEvents';
|
||||
import SigningKeyRoster from '@/components/security/SigningKeyRoster';
|
||||
|
||||
const SecuritySettings: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -37,7 +38,6 @@ const SecuritySettings: React.FC = () => {
|
|||
error,
|
||||
updateSetting,
|
||||
updateSettings,
|
||||
rotateSecurityKey,
|
||||
exportSettings,
|
||||
importSettings,
|
||||
resetToDefaults,
|
||||
|
|
@ -262,45 +262,6 @@ const SecuritySettings: React.FC = () => {
|
|||
},
|
||||
];
|
||||
|
||||
// Key Management Settings
|
||||
const keyManagementSettings: SecuritySetting[] = [
|
||||
{
|
||||
key: 'current_key_info',
|
||||
label: 'Current Key',
|
||||
type: 'text',
|
||||
value: localSettings?.key_management?.current_key?.key_id ?? 'No key configured',
|
||||
description: 'Currently active signing key',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'auto_rotation',
|
||||
label: 'Auto-Rotation',
|
||||
type: 'toggle',
|
||||
value: localSettings?.key_management?.auto_rotation ?? false,
|
||||
description: 'Automatically rotate signing keys on schedule',
|
||||
},
|
||||
{
|
||||
key: 'rotation_interval_days',
|
||||
label: 'Rotation Interval',
|
||||
type: 'number',
|
||||
value: localSettings?.key_management?.rotation_interval_days ?? 90,
|
||||
min: 7,
|
||||
max: 365,
|
||||
description: 'Days between automatic key rotations',
|
||||
disabled: !localSettings?.key_management?.auto_rotation,
|
||||
},
|
||||
{
|
||||
key: 'grace_period_days',
|
||||
label: 'Grace Period',
|
||||
type: 'number',
|
||||
value: localSettings?.key_management?.grace_period_days ?? 7,
|
||||
min: 1,
|
||||
max: 30,
|
||||
description: 'Days to accept old key after rotation',
|
||||
disabled: !localSettings?.key_management?.auto_rotation,
|
||||
},
|
||||
];
|
||||
|
||||
// Handle settings change
|
||||
const handleSettingChange = async (category: string, key: string, value: any) => {
|
||||
if (!localSettings) return;
|
||||
|
|
@ -362,19 +323,6 @@ const SecuritySettings: React.FC = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// Handle key rotation
|
||||
const handleRotateKey = () => {
|
||||
showConfirmation(
|
||||
'Rotate Security Key',
|
||||
'Rotating the security key will invalidate all existing agent connections. Agents will need to reconnect with the new key. This action cannot be undone.',
|
||||
async () => {
|
||||
await rotateSecurityKey({ reason: 'manual' });
|
||||
refetch();
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
// Handle reset to defaults
|
||||
const handleResetDefaults = () => {
|
||||
showConfirmation(
|
||||
|
|
@ -549,35 +497,7 @@ const SecuritySettings: React.FC = () => {
|
|||
);
|
||||
|
||||
case 'key-management':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SecurityCategorySection
|
||||
title="Key Management"
|
||||
description="Manage cryptographic keys used for signing and verification"
|
||||
settings={keyManagementSettings}
|
||||
onSettingChange={(key, value) => handleSettingChange('key_management', key, value)}
|
||||
disabled={loading}
|
||||
error={error?.message ?? null}
|
||||
/>
|
||||
|
||||
{/* Key Actions */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Key Actions</h3>
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={handleRotateKey}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Rotate Security Key
|
||||
</button>
|
||||
<p className="text-sm text-gray-600">
|
||||
Generate a new signing key. The old key will remain valid during the grace period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <SigningKeyRoster />;
|
||||
|
||||
case 'events':
|
||||
return <SecurityEvents />;
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ import React, { useState } from 'react';
|
|||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Terminal,
|
||||
Copy,
|
||||
Check,
|
||||
Shield,
|
||||
Server,
|
||||
Monitor,
|
||||
AlertTriangle,
|
||||
Code,
|
||||
Key
|
||||
Key,
|
||||
Code
|
||||
} from 'lucide-react';
|
||||
import { useRegistrationTokens } from '@/hooks/useRegistrationTokens';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
|
@ -18,7 +17,6 @@ import { useServerKeySecurity } from '@/hooks/useSecurity';
|
|||
|
||||
const AgentManagement: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string>('linux');
|
||||
const [selectedTokenId, setSelectedTokenId] = useState<string>('');
|
||||
const { data: tokens } = useRegistrationTokens({ is_active: true });
|
||||
|
|
@ -68,29 +66,8 @@ const AgentManagement: React.FC = () => {
|
|||
}
|
||||
];
|
||||
|
||||
const getServerUrl = () => {
|
||||
// Use API server port (31337) instead of web UI port (31336)
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
const port = hostname === 'localhost' || hostname === '127.0.0.1' ? ':31337' : '';
|
||||
return `${protocol}//${hostname}${port}`;
|
||||
};
|
||||
|
||||
const generateInstallCommand = (platform: typeof platforms[0]) => {
|
||||
if (!selectedToken) return '';
|
||||
const serverUrl = getServerUrl();
|
||||
const token = selectedToken.token;
|
||||
|
||||
if (platform.id === 'linux') {
|
||||
return `curl -sfL "${serverUrl}${platform.installScript}?token=${token}" | sudo bash`;
|
||||
} else if (platform.id === 'windows') {
|
||||
return `iwr "${serverUrl}${platform.installScript}?token=${token}" -UseBasicParsing -OutFile install.ps1; powershell -ExecutionPolicy Bypass -File install.ps1`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const formatTokenOptionLabel = (t: typeof availableTokens[number]) => {
|
||||
const prefix = t.token.slice(0, 12);
|
||||
const prefix = t.id.slice(0, 12);
|
||||
const seats = `${t.seats_used}/${t.max_seats} seats`;
|
||||
const label = t.label ? ` · ${t.label}` : '';
|
||||
let expiry = '';
|
||||
|
|
@ -101,22 +78,6 @@ const AgentManagement: React.FC = () => {
|
|||
return `${prefix}…${label} · ${seats}${expiry}`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, commandId: string) => {
|
||||
try {
|
||||
if (!text || text.trim() === '') {
|
||||
toast.error('No command to copy');
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedCommand(commandId);
|
||||
toast.success('Command copied to clipboard!');
|
||||
setTimeout(() => setCopiedCommand(null), 2000);
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
toast.error('Failed to copy command. Please copy manually.');
|
||||
}
|
||||
};
|
||||
|
||||
const selectedPlatformData = platforms.find(p => p.id === selectedPlatform);
|
||||
|
||||
return (
|
||||
|
|
@ -192,7 +153,9 @@ const AgentManagement: React.FC = () => {
|
|||
</div>
|
||||
{selectedToken && (
|
||||
<div className="mt-3 text-xs text-blue-800 bg-blue-100 rounded px-3 py-2 inline-block">
|
||||
Token: <code className="font-mono">{selectedToken.token}</code>
|
||||
ID: <code className="font-mono">{selectedToken.id.slice(0, 8)}…</code>
|
||||
{selectedToken.label && <> · {selectedToken.label}</>}
|
||||
· {selectedToken.seats_used}/{selectedToken.max_seats} seats used
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -237,78 +200,59 @@ const AgentManagement: React.FC = () => {
|
|||
{/* Installation Methods */}
|
||||
{selectedPlatformData && (
|
||||
<div className="space-y-8">
|
||||
{/* One-Liner Installation */}
|
||||
{selectedToken ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
{/* Install Command — generated at token creation time */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">2. One-Liner Installation (Recommended)</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900">2. Installation Command</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Automatically downloads and configures the agent for {selectedPlatformData.name}
|
||||
Registration tokens are hashed at rest. The install command (with embedded plaintext
|
||||
token) is shown once when you create the token — copy it then.
|
||||
</p>
|
||||
</div>
|
||||
<Terminal className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Installation Command {selectedPlatformData.id === 'windows' && <span className="text-blue-600">(Run in PowerShell as Administrator)</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
|
||||
<code>{generateInstallCommand(selectedPlatformData)}</code>
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => copyToClipboard(generateInstallCommand(selectedPlatformData), 'one-liner')}
|
||||
className="absolute top-2 right-2 p-2 bg-gray-700 text-white rounded hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{copiedCommand === 'one-liner' ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-yellow-900">Before Running</h4>
|
||||
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
|
||||
{selectedPlatformData.id === 'windows' ? (
|
||||
<>
|
||||
<li>• Open <strong>PowerShell as Administrator</strong></li>
|
||||
<li>• The script will download and install the agent to <code className="bg-yellow-100 px-1 rounded">%ProgramFiles%\RedFlag</code></li>
|
||||
<li>• A Windows service will be created and started automatically</li>
|
||||
<li>• Script is idempotent - safe to re-run for upgrades</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>• Run this command as <strong>root</strong> (use sudo)</li>
|
||||
<li>• The script will create a dedicated <code className="bg-yellow-100 px-1 rounded">redflag-agent</code> user</li>
|
||||
<li>• Limited sudo access will be configured via <code className="bg-yellow-100 px-1 rounded">/etc/sudoers.d/redflag-agent</code></li>
|
||||
<li>• Systemd service will be installed and enabled automatically</li>
|
||||
<li>• Script is idempotent - safe to re-run for upgrades</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
|
||||
<Terminal className="w-8 h-8 text-gray-400 mx-auto mb-3" />
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-1">Select a token above to generate the install command</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Reference information for the {selectedPlatformData.name} agent is shown below regardless.
|
||||
<div className="text-center py-6">
|
||||
<Link
|
||||
to="/settings/tokens"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
Create Token & Get Install Command
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500 mt-3">
|
||||
The install command is available only at token creation time.
|
||||
It cannot be retrieved later — only the SHA-256 hash is stored.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-yellow-900">Before Running</h4>
|
||||
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
|
||||
{selectedPlatformData.id === 'windows' ? (
|
||||
<>
|
||||
<li>• Open <strong>PowerShell as Administrator</strong></li>
|
||||
<li>• The script will download and install the agent to <code className="bg-yellow-100 px-1 rounded">%ProgramFiles%\RedFlag</code></li>
|
||||
<li>• A Windows service will be created and started automatically</li>
|
||||
<li>• Script is idempotent - safe to re-run for upgrades</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>• Run this command as <strong>root</strong> (use sudo)</li>
|
||||
<li>• The script will create a dedicated <code className="bg-yellow-100 px-1 rounded">redflag-agent</code> user</li>
|
||||
<li>• Limited sudo access will be configured via <code className="bg-yellow-100 px-1 rounded">/etc/sudoers.d/redflag-agent</code></li>
|
||||
<li>• Systemd service will be installed and enabled automatically</li>
|
||||
<li>• Script is idempotent - safe to re-run for upgrades</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Information */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
|
|
|
|||
|
|
@ -420,6 +420,21 @@ export interface RegistrationTokenStats {
|
|||
total_seats_available: number;
|
||||
}
|
||||
|
||||
// Ed25519 signing key — one entry per key the server has ever held.
|
||||
// `is_primary` marks the current signer; `is_active` includes accepted-but-
|
||||
// not-primary keys (verify but don't sign); deprecated keys carry a non-null
|
||||
// `deprecated_at` and `is_active=false`.
|
||||
export interface SigningKey {
|
||||
key_id: string;
|
||||
public_key: string;
|
||||
algorithm: string;
|
||||
is_active: boolean;
|
||||
is_primary: boolean;
|
||||
version: number;
|
||||
created_at: string;
|
||||
deprecated_at?: string | null;
|
||||
}
|
||||
|
||||
// Rate Limiting types
|
||||
//
|
||||
// The server exposes six fixed categories (not per-endpoint). Each route in
|
||||
|
|
|
|||
Loading…
Reference in a new issue