Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-05-30 14:09:50 -04:00
commit 54bed0711f
10 changed files with 402 additions and 190 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;

View file

@ -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[] }> => {

View file

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

View file

@ -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 &amp; 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">

View file

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