Skip to content
Merged
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
66 changes: 66 additions & 0 deletions packages/cli/src/commands/token.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import chalk from "chalk";
import postgres from "postgres";
import { getToken, resolveContext } from "../internal/index.js";
import { resolveApiClient } from "../internal/api-client.js";

Expand Down Expand Up @@ -97,3 +98,68 @@ export async function tokenCreateCommand(
function defaultTokenName(): string {
return `lobu-cli-${new Date().toISOString().slice(0, 10)}`;
}

/**
* Revoke a token by its `jti`. Inserts a row into `public.revoked_tokens`
* (created on demand) — the gateway's RevokedTokenStore checks this on every
* worker-token / settings-cookie verification, so the token is dead within
* one cache TTL (≤60s).
*/
export async function tokenRevokeCommand(
jti: string,
options: { expiresAt?: string }
): Promise<void> {
jti = jti.trim();
if (!jti) {
console.error(chalk.red("\n Missing <jti>.\n"));
process.exitCode = 1;
return;
}

const databaseUrl = process.env.DATABASE_URL?.trim();
if (!databaseUrl) {
console.error(
chalk.red(
"\n DATABASE_URL is not set. Run this from the same environment as the gateway.\n"
)
);
process.exitCode = 1;
return;
}

let expiresAt: Date;
if (options.expiresAt) {
expiresAt = new Date(options.expiresAt);
if (Number.isNaN(expiresAt.getTime())) {
console.error(chalk.red("\n --expires-at must be a valid ISO date.\n"));
process.exitCode = 1;
return;
}
} else {
expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
}

const sql = postgres(databaseUrl, { max: 1 });
try {
await sql.unsafe(`
CREATE TABLE IF NOT EXISTS public.revoked_tokens (
jti text PRIMARY KEY,
expires_at timestamptz NOT NULL
)
`);
await sql`
INSERT INTO public.revoked_tokens (jti, expires_at)
VALUES (${jti}, ${expiresAt})
ON CONFLICT (jti) DO UPDATE SET expires_at = GREATEST(public.revoked_tokens.expires_at, EXCLUDED.expires_at)
`;
} finally {
await sql.end({ timeout: 5 });
}

console.log(chalk.cyan("\n Token revoked"));
console.log(chalk.dim(` jti: ${jti}`));
console.log(chalk.dim(` expires: ${expiresAt.toISOString()}`));
console.log(
chalk.dim(" Effective within ~60s (gateway revocation cache TTL).\n")
);
}
12 changes: 12 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,18 @@ Memory:
}
);

token
.command("revoke <jti>")
.description("Revoke a worker/settings token by its jti (kill switch)")
.option(
"--expires-at <iso>",
"Original token expiry (ISO 8601); the revocation row is GC'd past it. Defaults to 24h from now."
)
.action(async (jti: string, options: { expiresAt?: string }) => {
const { tokenRevokeCommand } = await import("./commands/token.js");
await tokenRevokeCommand(jti, options);
});

// ─── context ────────────────────────────────────────────────────────
const context = program
.command("context")
Expand Down
58 changes: 58 additions & 0 deletions packages/core/src/__tests__/encryption-key-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { decrypt, encrypt } from "../utils/encryption";

// Regression coverage for ENCRYPTION_KEY parsing: `Buffer.from(x, "base64")`
// silently drops invalid characters, so a typo'd key could yield a short or
// garbled key. Parsing must round-trip and length-check before trusting it.
describe("ENCRYPTION_KEY validation", () => {
let originalKey: string | undefined;

beforeEach(() => {
originalKey = process.env.ENCRYPTION_KEY;
});

afterEach(() => {
if (originalKey !== undefined) {
process.env.ENCRYPTION_KEY = originalKey;
} else {
delete process.env.ENCRYPTION_KEY;
}
});

test("non-base64, non-hex junk key throws", () => {
process.env.ENCRYPTION_KEY = "this is not a valid key!! @#$%";
expect(() => encrypt("x")).toThrow("ENCRYPTION_KEY");
});

test("base64 string decoding to fewer than 32 bytes throws", () => {
// 16 bytes → 24-char canonical base64; passes the regex but is too short.
process.env.ENCRYPTION_KEY = Buffer.alloc(16, 9).toString("base64");
expect(() => encrypt("x")).toThrow("ENCRYPTION_KEY");
});

test("non-canonical base64 (chars that get silently dropped) throws", () => {
// Contains chars outside [A-Za-z0-9+/]; old code would drop them.
process.env.ENCRYPTION_KEY = `${Buffer.alloc(32, 1).toString("base64")}!!`;
expect(() => encrypt("x")).toThrow("ENCRYPTION_KEY");
});

test("valid 32-byte base64 key round-trips encrypt/decrypt", () => {
process.env.ENCRYPTION_KEY = Buffer.alloc(32, 42).toString("base64");
const enc = encrypt("base64 secret");
expect(decrypt(enc)).toBe("base64 secret");
});

test("valid 64-char hex key round-trips encrypt/decrypt", () => {
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const enc = encrypt("hex secret");
expect(decrypt(enc)).toBe("hex secret");
});

test("uppercase 64-char hex key round-trips encrypt/decrypt", () => {
process.env.ENCRYPTION_KEY =
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
const enc = encrypt("hex upper secret");
expect(decrypt(enc)).toBe("hex upper secret");
});
});
31 changes: 19 additions & 12 deletions packages/core/src/utils/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,34 @@ function getEncryptionKey(): Buffer {
);
}

// Try to decode as base64 first (most common format).
// Buffer.from with "base64" does not throw on invalid input — it silently
// discards non-base64 chars — so we only need a length check here.
const base64Buffer = Buffer.from(key, "base64");
if (base64Buffer.length === 32) {
cachedKey = base64Buffer;
return base64Buffer;
// Try to decode as base64 first (most common format). `Buffer.from(x,
// "base64")` silently drops non-base64 chars rather than throwing, so a
// typo'd key can yield a short/garbled buffer. Require canonical base64 and
// a clean round-trip before trusting the decoded bytes.
if (/^[A-Za-z0-9+/]+={0,2}$/.test(key) && key.length % 4 === 0) {
const base64Buffer = Buffer.from(key, "base64");
if (base64Buffer.length === 32 && base64Buffer.toString("base64") === key) {
cachedKey = base64Buffer;
return base64Buffer;
}
}

// Try as hex (must be exactly 64 hex characters for 32 bytes)
if (/^[0-9a-fA-F]{64}$/.test(key)) {
// Try as hex (must be exactly 64 hex characters for 32 bytes), again
// verifying the round-trip so partially-valid input is rejected.
if (/^[0-9a-fA-F]+$/.test(key) && key.length % 2 === 0) {
const hexBuffer = Buffer.from(key, "hex");
if (hexBuffer.length === 32) {
if (
hexBuffer.length === 32 &&
hexBuffer.toString("hex") === key.toLowerCase()
) {
cachedKey = hexBuffer;
return hexBuffer;
}
}

throw new Error(
"ENCRYPTION_KEY must be a base64 or hex encoded 32-byte key. " +
"Generate a valid key with: openssl rand -base64 32"
"ENCRYPTION_KEY must be a canonical base64 or hex encoded 32-byte key. " +
"Generate a valid key with: openssl rand -base64 32 (or openssl rand -hex 32)"
);
}

Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/worker/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { createLogger } from "../logger";
import { decrypt, encrypt } from "../utils/encryption";

Expand All @@ -20,6 +21,7 @@ export interface WorkerTokenData {
platform?: string;
sessionKey?: string;
traceId?: string; // Trace ID for end-to-end observability
jti?: string; // Unique token ID — enables targeted revocation
}

/**
Expand Down Expand Up @@ -57,6 +59,7 @@ export function generateWorkerToken(
platform: options.platform,
sessionKey: options.sessionKey,
traceId: options.traceId, // Trace ID for observability
jti: randomUUID(), // Unique token ID for targeted revocation
};

// Encrypt the payload
Expand Down Expand Up @@ -93,7 +96,13 @@ export function verifyWorkerToken(token: string): WorkerTokenData | null {
!Number.isNaN(parsedTtl) && parsedTtl > 0
? parsedTtl
: 2 * 60 * 60 * 1000;
const skewMs = 30 * 1000;
// Clock-skew tolerance between gateway and worker; override with WORKER_TOKEN_CLOCK_SKEW_MS.
const parsedSkew = parseInt(
process.env.WORKER_TOKEN_CLOCK_SKEW_MS ?? "",
10
);
const skewMs =
!Number.isNaN(parsedSkew) && parsedSkew >= 0 ? parsedSkew : 30 * 1000;
if (Date.now() - data.timestamp > ttl + skewMs) {
logger.error("Worker token rejected: expired");
return null;
Expand Down
101 changes: 101 additions & 0 deletions packages/server/src/__tests__/unit/egress-judge-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Egress-judge per-call timeout + circuit hygiene.
*
* The judge is awaited synchronously by the HTTP proxy when a `judge`-action
* rule matches, so a hung model call would otherwise stall an outbound
* request indefinitely. These tests pin: (1) a call that hangs past the
* timeout resolves to a deny verdict within ~timeout, and (2) consecutive
* timeouts count as breaker failures, so the circuit opens and later calls
* fail closed without touching the model.
*/
import { describe, expect, test } from "bun:test";
import type { ResolvedJudgeRule } from "../../gateway/permissions/policy-store.js";
import { EgressJudge } from "../../gateway/proxy/egress-judge/judge.js";
import type {
JudgeClient,
JudgeVerdict,
} from "../../gateway/proxy/egress-judge/types.js";

class HangingClient implements JudgeClient {
calls = 0;
async judge(): Promise<JudgeVerdict> {
this.calls++;
// Never settles — the judge's own timeout must rescue the caller.
return new Promise<JudgeVerdict>(() => {});
}
}

function rule(overrides: Partial<ResolvedJudgeRule> = {}): ResolvedJudgeRule {
return {
judgeName: "default",
policy: "allow only repos the user owns",
policyHash: "policy-hash-1",
...overrides,
};
}

describe("EgressJudge timeout", () => {
test("a hung judge call fails closed within ~timeout", async () => {
const client = new HangingClient();
const judge = new EgressJudge({ client, judgeTimeoutMs: 30 });
const started = Date.now();
const decision = await judge.decide(
{ agentId: "agent-a", hostname: "api.github.com" },
rule()
);
const elapsed = Date.now() - started;
expect(decision.verdict).toBe("deny");
expect(decision.source).toBe("judge-error");
expect(decision.reason).toContain("timed out");
expect(elapsed).toBeLessThan(500);
expect(client.calls).toBe(1);
});

test("consecutive timeouts trip the breaker and stop calling the model", async () => {
const client = new HangingClient();
const judge = new EgressJudge({
client,
judgeTimeoutMs: 20,
breakerFailureThreshold: 2,
breakerCooldownMs: 60_000,
});
// Distinct hostnames so the verdict cache never short-circuits the path.
for (let i = 0; i < 5; i++) {
const decision = await judge.decide(
{ agentId: "agent-a", hostname: `h${i}.example.com` },
rule()
);
expect(decision.verdict).toBe("deny");
}
// First two calls time out and count as breaker failures; the breaker
// then opens and the remaining three short-circuit without the model.
expect(client.calls).toBe(2);

const afterOpen = await judge.decide(
{ agentId: "agent-a", hostname: "another.example.com" },
rule()
);
expect(afterOpen.verdict).toBe("deny");
expect(afterOpen.source).toBe("circuit-open");
expect(client.calls).toBe(2);
});

test("EGRESS_JUDGE_TIMEOUT_MS env var sets the default", async () => {
const prev = process.env.EGRESS_JUDGE_TIMEOUT_MS;
process.env.EGRESS_JUDGE_TIMEOUT_MS = "25";
try {
const client = new HangingClient();
const judge = new EgressJudge({ client });
const started = Date.now();
const decision = await judge.decide(
{ agentId: "agent-a", hostname: "api.github.com" },
rule()
);
expect(decision.verdict).toBe("deny");
expect(Date.now() - started).toBeLessThan(500);
} finally {
if (prev === undefined) delete process.env.EGRESS_JUDGE_TIMEOUT_MS;
else process.env.EGRESS_JUDGE_TIMEOUT_MS = prev;
}
});
});
Loading
Loading