Watch
1
0
Fork
You've already forked RedFlag
0

security: hash registration tokens at rest, idempotency guard, README trust model

SEC-001: Registration tokens stored as SHA-256 hashes. Migration 046 adds
token_hash column, backfills from plaintext, drops token column. All queries
use hash. Token plaintext shown once at creation (reveal panel in UI), never
retrievable again. Follows the refresh-token pattern.

SEC-005: README "no sanitization" claims corrected — code correctly sanitizes
against log injection (ANSI stripping, control char replacement, truncation).
Wording updated to match reality.

SEC-008: Command creation with idempotency_key uses ON CONFLICT DO NOTHING
instead of blind insert. Prevents duplicate command execution.

Trust model: Ed25519 key rotation documented — signing_keys table supports
multiple concurrent active keys with a sliding window for zero-downtime
rotation. OSV.dev ecosystem coverage updated (apt, dnf added).
This commit is contained in:
Fimeg 2026-05-30 13:12:58 -04:00
commit b810b10162
8 changed files with 233 additions and 68 deletions

View file

@ -84,13 +84,13 @@ 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. Keys have TTL-based rotation — agents pre-cache new keys before the old ones expire, so rotation is zero-downtime.
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 with a sliding window — a new key is promoted to primary while the old key remains active, so agents that cached the previous key continue verifying successfully until the operator deactivates it. Rotation is zero-downtime; no coordinated restart required.
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.
Agent-server communication runs over HTTPS. The Ed25519 signing model is a defense-in-depth layer on top of that — commands can't be forged or replayed even if traffic is somehow intercepted or TLS is terminated at a proxy. The signing model doesn't assume the transport is trustworthy. Cert pinning and enforced TLS verification are on the roadmap.
Before a package is installed: the agent fetches the expected SHA-256 from the server, downloads the artifact, verifies the hash. Mismatch blocks the install. At approval time, OSV.dev is queried for known vulnerabilities in npm and PyPI packages.
Before a package is installed: the agent fetches the expected SHA-256 from the server, downloads the artifact, verifies the hash. Mismatch blocks the install. OSV.dev is queried for known vulnerabilities at discovery time (async, deduped) for npm, PyPI, apt, and dnf packages — results are visible in the dashboard before approval.
**Refresh-token rotation.** Each renewal mints a new refresh token and marks the old one consumed. Replaying a consumed token whose successor was also consumed means theft — the server revokes the entire token family and logs a security event. Agent crash-before-save is covered by accept-previous-once grace: a consumed token whose successor is still unconsumed gets a fresh one, not a revocation.
@ -135,7 +135,7 @@ Before a package is installed: the agent fetches the expected SHA-256 from the s
- **Idempotent installer** — re-running won't create duplicate agents
- **Proxy support** — HTTP/HTTPS/SOCKS5 for restricted networks
- **Native services** — systemd on Linux, Windows Services on Windows
- **Full audit trail** — all operations logged with context, nothing sanitized
- **Full audit trail** — all operations logged with context, sanitized against log injection
---
@ -207,11 +207,11 @@ Then install fresh with the standard one-liner.
RedFlag follows ETHOS:
- **Honest** — what you see is what you get
- **Transparent** — errors logged with full context, nothing sanitized
- **Transparent** — errors logged with full context, sanitized against injection
- **Secure** — hardware binding, cryptographic verification, local-only logging
- **Open standards** — no vendor lock-in, no cloud dependency, no telemetry
The maintainer runs this on their own infrastructure. Releases are versioned, migrations are idempotent. If something breaks, the error shows up in full — not sanitized into a generic failure message.
The maintainer runs this on their own infrastructure. Releases are versioned, migrations are idempotent. If something breaks, the error shows up in full — not swallowed into a generic failure message. Log output is sanitized against injection (ANSI stripping, control character replacement, field truncation) but the content is preserved.
Built for operators who'd rather own the problem than outsource it.

View file

@ -397,9 +397,10 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
return
}
// Step 2: Mark registration token as used (via stored procedure)
// Step 2: Mark registration token as used (via stored procedure — expects hash)
var tokenSuccess bool
if err := tx.QueryRow("SELECT mark_registration_token_used($1, $2)", registrationToken, agent.ID).Scan(&tokenSuccess); err != nil || !tokenSuccess {
tokenHash := queries.HashRegistrationToken(registrationToken)
if err := tx.QueryRow("SELECT mark_registration_token_used($1, $2)", tokenHash, agent.ID).Scan(&tokenSuccess); err != nil || !tokenSuccess {
log.Printf("[ERROR] [server] [registration] mark_token_failed error=%v success=%v", err, tokenSuccess)
c.JSON(http.StatusBadRequest, gin.H{"error": "registration token could not be consumed"})
return

View file

@ -0,0 +1,59 @@
-- Reverse SEC-001: restore plaintext token column.
-- WARNING: hash is one-way — rolled-back tokens will be empty strings.
ALTER TABLE registration_tokens ADD COLUMN token VARCHAR(64);
UPDATE registration_tokens SET token = '';
ALTER TABLE registration_tokens ALTER COLUMN token SET NOT NULL;
ALTER TABLE registration_tokens ADD CONSTRAINT registration_tokens_token_key UNIQUE (token);
ALTER TABLE registration_tokens DROP COLUMN token_hash;
DROP FUNCTION IF EXISTS mark_registration_token_used(VARCHAR, UUID);
CREATE FUNCTION mark_registration_token_used(token_input VARCHAR, agent_id_param UUID)
RETURNS BOOLEAN AS $$
DECLARE
rows_updated INTEGER;
token_id_val UUID;
new_seats_used INT;
token_max_seats INT;
BEGIN
SELECT id, seats_used + 1, max_seats INTO token_id_val, new_seats_used, token_max_seats
FROM registration_tokens
WHERE token = token_input
AND status = 'active'
AND expires_at > NOW()
AND seats_used < max_seats;
IF token_id_val IS NULL THEN
RETURN FALSE;
END IF;
UPDATE registration_tokens
SET seats_used = new_seats_used,
used_at = CASE WHEN used_at IS NULL THEN NOW() ELSE used_at END,
status = CASE WHEN new_seats_used >= token_max_seats THEN 'used' ELSE 'active' END
WHERE token = token_input AND status = 'active';
GET DIAGNOSTICS rows_updated = ROW_COUNT;
IF rows_updated > 0 THEN
INSERT INTO registration_token_usage (token_id, agent_id, used_at)
VALUES (token_id_val, agent_id_param, NOW())
ON CONFLICT (token_id, agent_id) DO NOTHING;
END IF;
RETURN rows_updated > 0;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION is_registration_token_valid(token_input VARCHAR)
RETURNS BOOLEAN AS $$
DECLARE
token_valid BOOLEAN;
BEGIN
SELECT (status = 'active' AND expires_at > NOW() AND seats_used < max_seats) INTO token_valid
FROM registration_tokens
WHERE token = token_input;
RETURN COALESCE(token_valid, FALSE);
END;
$$ LANGUAGE plpgsql;

View file

@ -0,0 +1,77 @@
-- SEC-001: Hash registration tokens at rest.
-- Registration tokens are now stored as SHA-256 hashes, matching the refresh
-- token model. The plaintext column is dropped; all queries use token_hash.
-- Add the hash column
ALTER TABLE registration_tokens ADD COLUMN token_hash VARCHAR(64);
-- Backfill: SHA-256 hash every existing plaintext token
UPDATE registration_tokens
SET token_hash = encode(sha256(token::bytea), 'hex');
-- Now enforce NOT NULL + uniqueness
ALTER TABLE registration_tokens ALTER COLUMN token_hash SET NOT NULL;
CREATE UNIQUE INDEX idx_registration_tokens_hash ON registration_tokens(token_hash);
-- Drop the plaintext column and its old unique constraint
ALTER TABLE registration_tokens DROP COLUMN token;
-- Rebuild is_registration_token_valid to use token_hash
CREATE OR REPLACE FUNCTION is_registration_token_valid(token_hash_input VARCHAR)
RETURNS BOOLEAN AS $$
DECLARE
token_valid BOOLEAN;
BEGIN
SELECT (status = 'active' AND expires_at > NOW() AND seats_used < max_seats) INTO token_valid
FROM registration_tokens
WHERE token_hash = token_hash_input;
RETURN COALESCE(token_valid, FALSE);
END;
$$ LANGUAGE plpgsql;
-- Rebuild mark_registration_token_used to use token_hash
DROP FUNCTION IF EXISTS mark_registration_token_used(VARCHAR, UUID);
CREATE FUNCTION mark_registration_token_used(token_hash_input VARCHAR, agent_id_param UUID)
RETURNS BOOLEAN AS $$
DECLARE
rows_updated INTEGER;
token_id_val UUID;
new_seats_used INT;
token_max_seats INT;
BEGIN
SELECT id, seats_used + 1, max_seats INTO token_id_val, new_seats_used, token_max_seats
FROM registration_tokens
WHERE token_hash = token_hash_input
AND status = 'active'
AND expires_at > NOW()
AND seats_used < max_seats;
IF token_id_val IS NULL THEN
RETURN FALSE;
END IF;
UPDATE registration_tokens
SET seats_used = new_seats_used,
used_at = CASE
WHEN used_at IS NULL THEN NOW()
ELSE used_at
END,
status = CASE
WHEN new_seats_used >= token_max_seats THEN 'used'
ELSE 'active'
END
WHERE id = token_id_val
AND status = 'active';
GET DIAGNOSTICS rows_updated = ROW_COUNT;
IF rows_updated > 0 THEN
INSERT INTO registration_token_usage (token_id, agent_id, used_at)
VALUES (token_id_val, agent_id_param, NOW())
ON CONFLICT (token_id, agent_id) DO NOTHING;
END IF;
RETURN rows_updated > 0;
END;
$$ LANGUAGE plpgsql;

View file

@ -34,7 +34,6 @@ func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error {
cmd.ExpiresAt = &exp
}
// Handle optional idempotency_key
if cmd.IdempotencyKey != nil {
query := `
INSERT INTO agent_commands (
@ -42,6 +41,7 @@ func (q *CommandQueries) CreateCommand(cmd *models.AgentCommand) error {
) VALUES (
:id, :agent_id, :command_type, :params, :status, :source, :signature, :key_id, :signed_at, :expires_at, :idempotency_key, :retried_from_id
)
ON CONFLICT (idempotency_key) DO NOTHING
`
_, err := q.db.NamedExec(query, cmd)
return err

View file

@ -1,7 +1,9 @@
package queries
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"time"
@ -10,13 +12,19 @@ import (
"github.com/jmoiron/sqlx"
)
// HashRegistrationToken creates a SHA-256 hash of a registration token for storage.
func HashRegistrationToken(token string) string {
hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:])
}
type RegistrationTokenQueries struct {
db *sqlx.DB
}
type RegistrationToken struct {
ID uuid.UUID `json:"id" db:"id"`
Token string `json:"token" db:"token"`
TokenHash string `json:"-" db:"token_hash"`
Label *string `json:"label" db:"label"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
@ -50,24 +58,25 @@ func NewRegistrationTokenQueries(db *sqlx.DB) *RegistrationTokenQueries {
return &RegistrationTokenQueries{db: db}
}
// CreateRegistrationToken creates a new registration token with seat tracking
// CreateRegistrationToken creates a new registration token with seat tracking.
// The caller passes the plaintext token; only the SHA-256 hash is stored.
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, maxSeats int, metadata map[string]interface{}) error {
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
// Ensure maxSeats is at least 1
if maxSeats < 1 {
maxSeats = 1
}
tokenHash := HashRegistrationToken(token)
query := `
INSERT INTO registration_tokens (token, label, expires_at, max_seats, metadata)
INSERT INTO registration_tokens (token_hash, label, expires_at, max_seats, metadata)
VALUES ($1, $2, $3, $4, $5)
`
_, err = q.db.Exec(query, token, label, expiresAt, maxSeats, metadataJSON)
_, err = q.db.Exec(query, tokenHash, label, expiresAt, maxSeats, metadataJSON)
if err != nil {
return fmt.Errorf("failed to create registration token: %w", err)
}
@ -75,18 +84,20 @@ func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string,
return nil
}
// ValidateRegistrationToken checks if a token is valid and has available seats
// ValidateRegistrationToken checks if a token is valid and has available seats.
// The caller passes the plaintext token; this function hashes it before querying.
func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*RegistrationToken, error) {
var regToken RegistrationToken
tokenHash := HashRegistrationToken(token)
query := `
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
SELECT id, token_hash, label, expires_at, created_at, used_at, used_by_agent_id,
revoked, revoked_at, revoked_reason, status, created_by, metadata,
max_seats, seats_used
FROM registration_tokens
WHERE token = $1 AND status = 'active' AND expires_at > NOW() AND seats_used < max_seats
WHERE token_hash = $1 AND status = 'active' AND expires_at > NOW() AND seats_used < max_seats
`
err := q.db.Get(&regToken, query, token)
err := q.db.Get(&regToken, query, tokenHash)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("invalid, expired, or seats full")
@ -97,14 +108,15 @@ func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*Reg
return &regToken, nil
}
// MarkTokenUsed marks a token as used by an agent
// With seat tracking, this increments seats_used and only marks status='used' when all seats are taken
// MarkTokenUsed marks a token as used by an agent.
// The caller passes the plaintext token; this function hashes it before calling
// the stored procedure (which now matches on token_hash).
func (q *RegistrationTokenQueries) MarkTokenUsed(token string, agentID uuid.UUID) error {
// Call the PostgreSQL function that handles seat tracking logic
tokenHash := HashRegistrationToken(token)
query := `SELECT mark_registration_token_used($1, $2)`
var success bool
err := q.db.QueryRow(query, token, agentID).Scan(&success)
err := q.db.QueryRow(query, tokenHash, agentID).Scan(&success)
if err != nil {
return fmt.Errorf("failed to mark token as used: %w", err)
}
@ -153,7 +165,7 @@ func (q *RegistrationTokenQueries) GetAgentsBoundToToken(tokenID uuid.UUID) ([]B
func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]RegistrationToken, error) {
var tokens []RegistrationToken
query := `
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
SELECT id, token_hash, label, expires_at, created_at, used_at, used_by_agent_id,
revoked, revoked_at, revoked_reason, status, created_by, metadata,
max_seats, seats_used
FROM registration_tokens
@ -173,7 +185,7 @@ func (q *RegistrationTokenQueries) GetActiveRegistrationTokens() ([]Registration
func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) ([]RegistrationToken, error) {
var tokens []RegistrationToken
query := `
SELECT id, token, label, expires_at, created_at, used_at, used_by_agent_id,
SELECT id, token_hash, label, expires_at, created_at, used_at, used_by_agent_id,
revoked, revoked_at, revoked_reason, status, created_by, metadata,
max_seats, seats_used
FROM registration_tokens
@ -199,16 +211,17 @@ func (q *RegistrationTokenQueries) GetAllRegistrationTokens(limit, offset int) (
// — that path is surfaced as an explicit per-agent operator action, not a side
// effect of revoking the issuing token.
func (q *RegistrationTokenQueries) RevokeRegistrationToken(token, reason string) error {
tokenHash := HashRegistrationToken(token)
query := `
UPDATE registration_tokens
SET status = 'revoked',
revoked = true,
revoked_at = NOW(),
revoked_reason = $1
WHERE token = $2
WHERE token_hash = $2
`
result, err := q.db.Exec(query, reason, token)
result, err := q.db.Exec(query, reason, tokenHash)
if err != nil {
return fmt.Errorf("failed to revoke token: %w", err)
}

View file

@ -5,11 +5,8 @@ import {
Plus,
Search,
RefreshCw,
Download,
Trash2,
Copy,
Eye,
EyeOff,
AlertTriangle,
CheckCircle,
Clock,
@ -33,7 +30,7 @@ const TokenManagement: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'used' | 'expired' | 'revoked'>('all');
const [showCreateForm, setShowCreateForm] = useState(false);
const [showToken, setShowToken] = useState<Record<string, boolean>>({});
const [createdToken, setCreatedToken] = useState<{ token: string; install_command: string } | null>(null);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
@ -68,9 +65,10 @@ const TokenManagement: React.FC = () => {
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault();
createToken.mutate(formData, {
onSuccess: () => {
onSuccess: (data: any) => {
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
setShowCreateForm(false);
setCreatedToken({ token: data.token, install_command: data.install_command });
refetch();
},
});
@ -94,24 +92,11 @@ const TokenManagement: 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 copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
// Show success feedback
};
const copyInstallCommand = async (token: string) => {
const serverUrl = getServerUrl();
const command = `curl -sfL "${serverUrl}/api/v1/install/linux?token=${token}" | sudo bash`;
await navigator.clipboard.writeText(command);
};
const getStatusColor = (token: RegistrationToken) => {
if (token.status === 'revoked') return 'text-gray-500';
@ -299,6 +284,59 @@ const TokenManagement: React.FC = () => {
</div>
)}
{/* Created token reveal — shown once, dismissed by the operator */}
{createdToken && (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 mb-8">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<h3 className="text-lg font-semibold text-green-900">Token Created</h3>
</div>
<button
onClick={() => setCreatedToken(null)}
className="text-green-600 hover:text-green-800 text-sm"
>
Dismiss
</button>
</div>
<p className="text-sm text-green-800 mb-3">
Copy this token now. It cannot be retrieved again only a hash is stored.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Token</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-sm bg-white border border-green-200 px-3 py-2 rounded select-all">
{createdToken.token}
</code>
<button
onClick={() => copyToClipboard(createdToken.token)}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
title="Copy token"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-green-700 mb-1">Install command</label>
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-xs bg-white border border-green-200 px-3 py-2 rounded select-all overflow-x-auto">
{createdToken.install_command}
</code>
<button
onClick={() => copyToClipboard(createdToken.install_command)}
className="px-3 py-2 text-green-700 bg-white border border-green-200 rounded hover:bg-green-100"
title="Copy install command"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
)}
{/* Filters and Search */}
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-8">
<div className="flex flex-col lg:flex-row gap-4">
@ -412,16 +450,8 @@ const TokenManagement: React.FC = () => {
{filteredTokens.map((token) => (
<tr key={token.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="font-mono text-sm bg-gray-100 px-3 py-2 rounded">
{showToken[token.id] ? token.token : '•••••••••••••••••'}
</div>
<button
onClick={() => setShowToken({ ...showToken, [token.id]: !showToken[token.id] })}
className="text-gray-400 hover:text-gray-600"
>
{showToken[token.id] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<div className="font-mono text-sm text-gray-500 bg-gray-100 px-3 py-2 rounded">
{token.id.slice(0, 8)}...
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
@ -454,20 +484,6 @@ const TokenManagement: React.FC = () => {
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button
onClick={() => copyToClipboard(token.token)}
className="text-blue-600 hover:text-blue-800"
title="Copy token"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={() => copyInstallCommand(token.token)}
className="text-blue-600 hover:text-blue-800"
title="Copy install command"
>
<Download className="w-4 h-4" />
</button>
{token.status === 'active' && (
<button
onClick={() => handleRevokeToken(token.id, token.label || 'this token')}

View file

@ -388,7 +388,6 @@ export interface ApiError {
// Registration Token types
export interface RegistrationToken {
id: string;
token: string;
label: string | null;
expires_at: string;
created_at: string;