git-subtree-dir: vendor/ruvector git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
19 KiB
Edge-Net Relay Security Audit Report
Date: 2026-01-03
Auditor: Code Review Agent
Component: Edge-Net WebSocket Relay Server (/relay/index.js)
Version: v0.1.0
Executive Summary
A comprehensive security audit was conducted on the Edge-Net relay server, focusing on authentication, authorization, and protection against common attack vectors. The relay implements strong security controls for task assignment and credit distribution, with Firestore-backed QDAG (Quantum Directed Acyclic Graph) ledger as the source of truth.
Overall Security Rating: ✅ GOOD (with minor recommendations)
Key Findings
| Category | Status | Details |
|---|---|---|
| Task Completion Spoofing | ✅ SECURE | Protected by assignment tracking |
| Replay Attacks | ✅ SECURE | Protected by completed tasks set |
| Credit Self-Reporting | ✅ SECURE | Disabled - relay-only crediting |
| Public Key Spoofing | ⚠️ MOSTLY SECURE | See recommendations |
| Rate Limiting | ✅ IMPLEMENTED | Per-node message throttling |
| Message Size Limits | ✅ IMPLEMENTED | 64KB max payload |
| Connection Limits | ✅ IMPLEMENTED | 5 connections per IP |
| Origin Validation | ✅ IMPLEMENTED | CORS whitelist |
Security Architecture
1. QDAG Ledger (Source of Truth)
Implementation: Lines 66-142
// Firestore-backed persistent credit ledger
const ledgerCollection = firestore.collection('edge-net-qdag');
// Credits ONLY increase via verified task completions
async function creditAccount(publicKey, amount, taskId) {
const ledger = await loadLedger(publicKey);
ledger.earned += amount;
ledger.tasksCompleted += 1;
await saveLedger(publicKey, ledger);
}
Security Properties:
- ✅ Credits keyed by public key (identity-based, not node-based)
- ✅ Persistent across sessions (Firestore)
- ✅ Server-side only (clients cannot modify)
- ✅ Atomic operations with in-memory cache
Attack Vector Analysis
🔴 Attack Vector 1: Task Completion Spoofing
Description: Malicious node tries to complete tasks not assigned to them.
Protection Mechanism (Lines 61-64, 222-229, 411-423):
// Track assigned tasks with assignment metadata
const assignedTasks = new Map(); // taskId -> { assignedTo, submitter, maxCredits }
// On task assignment (lines 222-229)
assignedTasks.set(task.id, {
assignedTo: targetNodeId,
assignedToPublicKey: targetWs.publicKey,
submitter: task.submitter,
maxCredits: task.maxCredits,
assignedAt: Date.now(),
});
// On task completion (lines 411-423)
const assignment = assignedTasks.get(taskId);
if (!assignment) {
console.warn(`Task ${taskId} not found or expired`);
ws.send(JSON.stringify({ type: 'error', message: 'Task not found or expired' }));
break;
}
if (assignment.assignedTo !== nodeId) {
console.warn(`Task ${taskId} assigned to ${assignment.assignedTo}, not ${nodeId} - SPOOFING ATTEMPT`);
ws.send(JSON.stringify({ type: 'error', message: 'Task not assigned to you' }));
break;
}
Security Assessment: ✅ SECURE
- Assignment tracked with node ID verification
- Only assigned node can complete task
- Clear error messages for debugging (but could be more generic for production)
Test Coverage: relay-security.test.ts - "Task Completion Spoofing" suite
🔴 Attack Vector 2: Replay Attacks
Description: Malicious node tries to complete the same task multiple times to earn duplicate credits.
Protection Mechanism (Lines 64, 425-430, 441):
const completedTasks = new Set(); // Prevent double completion
// On task completion (lines 425-430)
if (completedTasks.has(taskId)) {
console.warn(`Task ${taskId} already completed - REPLAY ATTEMPT from ${nodeId}`);
ws.send(JSON.stringify({ type: 'error', message: 'Task already completed' }));
break;
}
// Mark completed BEFORE crediting (line 441)
completedTasks.add(taskId);
assignedTasks.delete(taskId);
Security Assessment: ✅ SECURE
- Tasks marked complete before crediting (prevents race conditions)
- Permanent replay prevention (Set-based tracking)
- Assignment deleted after completion
Potential Issue: ⚠️ completedTasks Set grows unbounded
Recommendation: Implement periodic cleanup for old completed tasks:
// Cleanup completed tasks older than 24 hours
setInterval(() => {
const CLEANUP_AGE = 24 * 60 * 60 * 1000; // 24 hours
// Track completion timestamps and remove old entries
}, 60 * 60 * 1000); // Every hour
Test Coverage: relay-security.test.ts - "Replay Attacks" suite
🔴 Attack Vector 3: Credit Self-Reporting
Description: Malicious client tries to submit their own credit values to inflate balance.
Protection Mechanism (Lines 612-622):
case 'ledger_update':
// DEPRECATED: Clients cannot self-report credits
{
console.warn(`[QDAG] REJECTED ledger_update from ${nodeId} - clients cannot self-report credits`);
ws.send(JSON.stringify({
type: 'error',
message: 'Credit self-reporting disabled. Credits earned via task completions only.',
}));
}
break;
Security Assessment: ✅ SECURE
ledger_updatemessages explicitly rejected- Only
creditAccount()function can increase earned credits creditAccount()only called after verified task completion (lines 450-451)- Firestore ledger is source of truth
Additional Security:
ledger_syncis read-only (lines 570-610) - returns balance from Firestore- No client-submitted credit values accepted anywhere
Test Coverage: relay-security.test.ts - "Credit Self-Reporting" suite
🔴 Attack Vector 4: Public Key Spoofing
Description: Malicious node tries to use another user's public key to claim their credits or identity.
Protection Mechanism: Lines 360-366, 432-438, 584
Current Implementation:
// On registration (lines 360-366)
if (message.publicKey) {
ws.publicKey = message.publicKey;
console.log(`Node registered: ${nodeId} with identity ${message.publicKey.slice(0, 16)}...`);
}
// On task completion (lines 432-438)
const processorPublicKey = assignment.assignedToPublicKey || ws.publicKey;
if (!processorPublicKey) {
ws.send(JSON.stringify({ type: 'error', message: 'Public key required for credit' }));
break;
}
// Credits go to the PUBLIC KEY stored in assignment
await creditAccount(processorPublicKey, rewardRuv, taskId);
Security Assessment: ⚠️ MOSTLY SECURE (with caveats)
What IS Secure:
- ✅ Credits keyed by public key (same identity = same balance everywhere)
- ✅ Public key stored at assignment time (prevents mid-flight changes)
- ✅ Credits awarded to
assignment.assignedToPublicKey(not currentws.publicKey) - ✅ Multiple nodes can share public key (multi-device support by design)
What IS NOT Fully Protected:
- ⚠️ No cryptographic signature verification (Line 281-286)
// CURRENT: Placeholder validation
function validateSignature(nodeId, message, signature, publicKey) {
// In production, verify Ed25519 signature from PiKey
// For now, accept if nodeId matches registered node
return nodes.has(nodeId);
}
- ⚠️ Clients can claim any public key without proving ownership
- ⚠️ This allows "read-only spoofing" - checking another user's balance
Impact Assessment:
| Scenario | Risk Level | Impact |
|---|---|---|
| Checking another user's balance | 🟡 LOW | Privacy leak, but no financial impact |
| Claiming another user's public key at registration | 🟡 LOW | Cannot steal existing credits (assigned at task time) |
| Earning credits to someone else's key | 🟡 LOW | Self-harm (attacker works for victim's benefit) |
| Completing tasks assigned to victim | 🔴 NONE | Protected by assignedTo node ID check |
Recommendations:
- Implement Ed25519 Signature Verification (CRITICAL for production):
import { verify } from '@noble/ed25519';
async function validateSignature(message, signature, publicKey) {
try {
const msgHash = createHash('sha256').update(JSON.stringify(message)).digest();
return await verify(signature, msgHash, publicKey);
} catch {
return false;
}
}
// Require signature on sensitive operations
case 'task_complete':
if (!message.signature || !validateSignature(message, message.signature, ws.publicKey)) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid signature' }));
break;
}
// ... rest of task completion logic
- Add Challenge-Response on Registration:
case 'register':
// Send challenge
const challenge = randomBytes(32).toString('hex');
challenges.set(nodeId, challenge);
ws.send(JSON.stringify({
type: 'challenge',
challenge: challenge,
}));
break;
case 'challenge_response':
const challenge = challenges.get(nodeId);
if (!validateSignature({ challenge }, message.signature, message.publicKey)) {
ws.close(4003, 'Invalid signature');
break;
}
// Complete registration...
- Rate-limit balance queries to prevent enumeration attacks:
case 'ledger_sync':
if (!checkRateLimit(`${nodeId}-ledger-sync`, 10, 60000)) { // 10 per minute
ws.send(JSON.stringify({ type: 'error', message: 'Balance query rate limit' }));
break;
}
Test Coverage: relay-security.test.ts - "Public Key Spoofing" suite
Additional Security Features
5. Rate Limiting
Implementation: Lines 45-46, 265-279, 346-350
const rateLimits = new Map();
const RATE_LIMIT_MAX = 100; // max messages per window
const RATE_LIMIT_WINDOW = 60000; // 1 minute
function checkRateLimit(nodeId) {
const now = Date.now();
const limit = rateLimits.get(nodeId) || { count: 0, windowStart: now };
if (now - limit.windowStart > RATE_LIMIT_WINDOW) {
limit.count = 0;
limit.windowStart = now;
}
limit.count++;
rateLimits.set(nodeId, limit);
return limit.count <= RATE_LIMIT_MAX;
}
Security Assessment: ✅ GOOD
- Per-node rate limiting (not global)
- Sliding window implementation
- Enforced after registration
Recommendations:
- ✅ Consider adaptive rate limits based on node reputation
- ✅ Add separate limits for expensive operations (task_submit, ledger_sync)
6. Message Size Limits
Implementation: Lines 21, 318, 338-342
const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB
ws._maxPayload = MAX_MESSAGE_SIZE;
if (data.length > MAX_MESSAGE_SIZE) {
ws.send(JSON.stringify({ type: 'error', message: 'Message too large' }));
return;
}
Security Assessment: ✅ GOOD
- Prevents DoS via large payloads
- Enforced at both WebSocket and application layer
- 64KB is reasonable for control messages
7. Connection Limits
Implementation: Lines 22, 308-315, 671-677
const MAX_CONNECTIONS_PER_IP = 5;
const ipConnections = new Map();
// On connection
const ipCount = ipConnections.get(clientIP) || 0;
if (ipCount >= MAX_CONNECTIONS_PER_IP) {
console.log(`Rejected connection: too many from ${clientIP}`);
ws.close(4002, 'Too many connections');
return;
}
ipConnections.set(clientIP, ipCount + 1);
// On close
const currentCount = ipConnections.get(clientIP) || 1;
if (currentCount <= 1) {
ipConnections.delete(clientIP);
} else {
ipConnections.set(clientIP, currentCount - 1);
}
Security Assessment: ✅ GOOD
- Prevents connection flooding from single IP
- Properly tracks connection/disconnection
- 5 connections is reasonable for multi-device scenarios
Potential Issue: ⚠️ Does not defend against distributed attacks (multiple IPs)
Recommendations:
- Add global connection limit (e.g., max 1000 total connections)
- Implement connection rate limiting (max N new connections per minute per IP)
8. Origin Validation
Implementation: Lines 27-37, 255-263, 301-306
const ALLOWED_ORIGINS = new Set([
'http://localhost:3000',
'https://edge-net.ruv.io',
// ... other allowed origins
]);
function isOriginAllowed(origin) {
if (!origin) return true; // Allow Node.js connections
if (ALLOWED_ORIGINS.has(origin)) return true;
if (origin.startsWith('http://localhost:')) return true; // Dev mode
return false;
}
if (!isOriginAllowed(origin)) {
ws.close(4001, 'Unauthorized origin');
return;
}
Security Assessment: ✅ GOOD for browser connections
Recommendations:
- ⚠️
if (!origin) return trueallows any non-browser client (CLI, scripts) - Consider requiring API key or authentication for non-browser connections:
function isOriginAllowed(origin, headers) {
if (!origin) {
// Non-browser connection - require API key
const apiKey = headers['x-api-key'];
return apiKey && validateAPIKey(apiKey);
}
return ALLOWED_ORIGINS.has(origin) || origin.startsWith('http://localhost:');
}
9. Task Expiration
Implementation: Lines 243-253
// Cleanup old assigned tasks (expire after 5 minutes)
setInterval(() => {
const now = Date.now();
const TASK_TIMEOUT = 5 * 60 * 1000; // 5 minutes
for (const [taskId, task] of assignedTasks) {
if (now - task.assignedAt > TASK_TIMEOUT) {
assignedTasks.delete(taskId);
console.log(`Task ${taskId} expired (not completed in time)`);
}
}
}, 60000); // Check every minute
Security Assessment: ✅ GOOD
- Prevents stale task assignments
- 5-minute timeout is reasonable
- Automatically cleans up abandoned tasks
Recommendation: ⚠️ Consider re-queuing expired tasks:
if (now - task.assignedAt > TASK_TIMEOUT) {
assignedTasks.delete(taskId);
// Re-queue task if original submitter still connected
if (nodes.has(task.submitter)) {
taskQueue.push({
id: taskId,
submitter: task.submitter,
// ... task details
});
console.log(`Task ${taskId} re-queued after timeout`);
}
}
10. Heartbeat Timeout
Implementation: Lines 25, 320-329
const CONNECTION_TIMEOUT = 30000; // 30s heartbeat timeout
let heartbeatTimeout;
const resetHeartbeat = () => {
clearTimeout(heartbeatTimeout);
heartbeatTimeout = setTimeout(() => {
console.log(`Node ${nodeId} timed out`);
ws.terminate();
}, CONNECTION_TIMEOUT);
};
resetHeartbeat();
// Reset on any message
ws.on('message', async (data) => {
resetHeartbeat();
// ...
});
Security Assessment: ✅ GOOD
- Prevents zombie connections
- 30-second timeout with implicit heartbeat (any message resets)
- Explicit heartbeat message type also supported (lines 651-657)
Remaining Vulnerabilities
🔴 CRITICAL (Production Blockers)
- No Cryptographic Signature Verification
- Impact: Cannot prove public key ownership
- Mitigation: Implement Ed25519 signature validation (see recommendations above)
- Priority: CRITICAL for production
🟡 MEDIUM (Should Address)
-
Unbounded
completedTasksSet Growth- Impact: Memory leak over time
- Mitigation: Implement periodic cleanup of old completed tasks
- Priority: MEDIUM
-
No Global Connection Limit
- Impact: Distributed attack from many IPs could exhaust resources
- Mitigation: Add global max connection limit
- Priority: MEDIUM
-
Permissive Non-Browser Access
- Impact: Any script can connect without authentication
- Mitigation: Require API key for non-browser connections
- Priority: MEDIUM (depends on use case)
🟢 LOW (Nice to Have)
-
Error Messages Reveal Internal State
- Impact: Attackers can infer system behavior from detailed errors
- Mitigation: Use generic error messages in production, detailed in logs
- Priority: LOW
-
No Firestore Access Control Validation
- Impact: Assumes Firestore security rules are correctly configured
- Mitigation: Document required Firestore security rules
- Priority: LOW (infrastructure concern)
Firestore Security Rules Required
The relay assumes these Firestore security rules are in place:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /edge-net-qdag/{publicKey} {
// Only server (via Admin SDK) can write
allow read: if false; // Not public
allow write: if false; // Server-only
}
}
}
Validation: ⚠️ Not tested in this audit - infrastructure team should verify.
Test Coverage Summary
Created comprehensive security test suite: /tests/relay-security.test.ts
Test Suites:
- ✅ Task Completion Spoofing (2 tests)
- ✅ Replay Attacks (1 test)
- ✅ Credit Self-Reporting (2 tests)
- ✅ Public Key Spoofing (2 tests)
- ✅ Rate Limiting (1 test)
- ✅ Message Size Limits (1 test)
- ✅ Connection Limits (1 test)
- ✅ Task Expiration (1 test)
- ✅ Combined Attack Scenario (1 test)
Total: 12 security tests
To Run Tests:
cd /workspaces/ruvector/examples/edge-net
npm install --save-dev @jest/globals ws @types/ws
npm test tests/relay-security.test.ts
Recommendations Summary
Immediate (Before Production)
- ✅ Implement Ed25519 signature verification for public key ownership
- ✅ Add challenge-response on node registration
- ✅ Implement completedTasks cleanup to prevent memory leak
Short-term (1-2 weeks)
- ✅ Add global connection limit (e.g., 1000 max total)
- ✅ Require API keys for non-browser connections
- ✅ Add separate rate limits for expensive operations
- ✅ Rate-limit balance queries to prevent enumeration
Long-term (1-3 months)
- ✅ Implement reputation-based rate limiting
- ✅ Add connection rate limiting (per IP)
- ✅ Use generic error messages in production
- ✅ Document Firestore security rules and validate configuration
- ✅ Add metrics and monitoring for attack detection
Conclusion
The Edge-Net relay server demonstrates strong security fundamentals with excellent protection against:
- ✅ Task completion spoofing
- ✅ Replay attacks
- ✅ Credit self-reporting
- ✅ Basic DoS attacks
The QDAG ledger architecture is well-designed with Firestore as source of truth and server-side-only crediting.
Primary concern: Lack of cryptographic signature verification means public key ownership is not proven. This is acceptable for testing/development but MUST be implemented before production deployment.
Overall Assessment: System is secure for testing/development. Implement critical recommendations before production.
Audit Completed: 2026-01-03 Next Review: After signature verification implementation