Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions src/host/creature-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Per-creature API token authentication.
*
* Each creature gets a unique token derived deterministically from
* HMAC(orchestrator_secret, creature_name). This means:
* - Tokens survive orchestrator restarts (same secret → same tokens)
* - No persistence layer needed for tokens
* - Creatures still receive tokens at spawn time via CREATURE_TOKEN env var
*
* The orchestrator secret is read from OPENSEED_SECRET env var or auto-generated
* and written to ~/.openseed/secret on first run.
*
* Closes #12
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import type { IncomingMessage } from 'node:http';

/** Cached orchestrator secret. */
let orchestratorSecret: string | null = null;

/** Derived token cache: creature name → token (avoids re-deriving on every auth check). */
const tokenCache = new Map<string, string>();

/** Constant-time string comparison (prevents timing attacks). */
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

/**
* Get (or create) the orchestrator secret.
* Priority: OPENSEED_SECRET env var > ~/.openseed/secret file > auto-generate.
*/
function getOrchestratorSecret(): string {
if (orchestratorSecret) return orchestratorSecret;

// 1. Check env var
if (process.env.OPENSEED_SECRET) {
orchestratorSecret = process.env.OPENSEED_SECRET;
return orchestratorSecret;
}

// 2. Check persisted secret file
const secretDir = join(homedir(), '.openseed');
const secretPath = join(secretDir, 'secret');

if (existsSync(secretPath)) {
orchestratorSecret = readFileSync(secretPath, 'utf-8').trim();
return orchestratorSecret;
}

// 3. Generate and persist (best-effort — if persistence fails, tokens regenerate on restart)
orchestratorSecret = randomBytes(32).toString('hex');
try {
mkdirSync(secretDir, { recursive: true });
writeFileSync(secretPath, orchestratorSecret, { mode: 0o600 });
} catch {
console.warn('[auth] could not persist orchestrator secret to', secretPath);
}
return orchestratorSecret;
}

/**
* Derive a deterministic token for a creature.
* HMAC-SHA256(orchestrator_secret, creature_name) → hex string.
*/
export function deriveCreatureToken(name: string): string {
const cached = tokenCache.get(name);
if (cached) return cached;

const secret = getOrchestratorSecret();
const token = createHmac('sha256', secret).update(name).digest('hex');
tokenCache.set(name, token);
return token;
}

/** Evict a creature's cached token (on destroy). The HMAC-derived token itself remains valid — this only clears the cache entry. */
export function evictCreatureTokenCache(name: string): void {
tokenCache.delete(name);
}

/** Check if a request is from localhost (dashboard). */
function isLocalhost(req: IncomingMessage): boolean {
const addr = req.socket.remoteAddress || '';
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
}

/**
* Authenticate a creature control request.
*
* Returns { ok: true, caller } on success, or { ok: false, status, message } on failure.
*
* Rules:
* - Localhost requests (dashboard) are always allowed
* - Remote requests must provide Bearer token
* - Token must match the target creature (self-management only)
*/
export function authenticateCreatureRequest(
req: IncomingMessage,
targetCreature: string,
): { ok: true; caller: string } | { ok: false; status: number; message: string } {
// Dashboard access from localhost is always allowed
if (isLocalhost(req)) {
return { ok: true, caller: 'dashboard' };
}

const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return {
ok: false,
status: 401,
message: 'Authentication required. Provide Bearer token via Authorization header.',
};
}

const token = authHeader.slice(7);

// Re-derive the expected token for the target creature directly.
// This avoids relying on the token cache (which is empty after orchestrator restart)
// and eliminates the O(n) cache scan.
const expected = deriveCreatureToken(targetCreature);

if (!safeEqual(expected, token)) {
return { ok: false, status: 401, message: 'Invalid token.' };
}

return { ok: true, caller: targetCreature };
}
15 changes: 15 additions & 0 deletions src/host/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
} from '../shared/paths.js';
import { spawnCreature } from '../shared/spawn.js';
import { Event } from '../shared/types.js';
import { authenticateCreatureRequest, deriveCreatureToken } from './creature-auth.js';
import {
getSpendingCap,
loadGlobalConfig,
Expand All @@ -62,6 +63,9 @@ import {
initPricing,
lookupPricing,
} from './costs.js';

/** Valid creature control actions. */
const CONTROL_ACTIONS = new Set(["start", "stop", "restart", "rebuild", "wake", "message"]);
import { EventStore } from './events.js';
import {
activateInstallation,
Expand Down Expand Up @@ -1286,6 +1290,17 @@ export class Orchestrator {
return;
}


// Auth gate — control actions require valid creature token (or localhost/dashboard)
if (CONTROL_ACTIONS.has(action) && req.method === "POST") {
const auth = authenticateCreatureRequest(req, name);
if (!auth.ok) {
res.writeHead(auth.status, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: auth.message }));
return;
}
}

if (action === 'start' && req.method === 'POST') {
const health = getStatus();
if (health.status !== 'healthy') {
Expand Down
4 changes: 3 additions & 1 deletion src/host/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import fsSync from 'node:fs';
import net from 'node:net';
import path from 'node:path';


import { deriveCreatureToken, evictCreatureTokenCache } from './creature-auth.js';
import { Event } from '../shared/types.js';
import {
getCurrentSHA,
Expand Down Expand Up @@ -103,6 +103,7 @@ export class CreatureSupervisor {
this.clearTimers();
try { execSync(`docker stop ${this.containerName()}`, { stdio: 'ignore', timeout: 15_000 }); } catch {}
this.status = 'stopped';
evictCreatureTokenCache(this.name);
this.sleepReason = 'user';
this.creature = null;
}
Expand Down Expand Up @@ -322,6 +323,7 @@ export class CreatureSupervisor {
'-e', `ANTHROPIC_BASE_URL=${orchestratorUrl}`,
'-e', `HOST_URL=${orchestratorUrl}`,
'-e', `CREATURE_NAME=${name}`,
'-e', `CREATURE_TOKEN=${deriveCreatureToken(name)}`,
'-e', 'PORT=7778',
'-e', `AUTO_ITERATE=${autoIterate ? 'true' : 'false'}`,
...(this.config.model ? ['-e', `LLM_MODEL=${this.config.model}`] : []),
Expand Down