Heimdall/tests/caller-identity-soul.test.ts
Meta Vens 1c38d12050 Add Joystick as a confidential, rotating managed-connection provider
Joystick joins the catalog (Basic client auth, rotatesRefreshTokens), resolves
identity by channel id, and is a streampixels managed-connection provider only:
it is not an identity provider for the profile (ruling signin-waits-for-public-bot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:13:40 +02:00

292 lines
12 KiB
TypeScript

// Soul falsification probes for the caller-identity cut (epiphany/heimdall-caller-identity).
//
// Two kinds of test live here:
// - plain `it(...)`: an invariant the cut claims and that currently holds.
// - `it.fails(...)`: an invariant the cut does NOT yet enforce. vitest passes
// the case while the body fails; when someone closes the hole the case
// flips to a real failure and must be promoted to a plain `it`.
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { encode } from "@msgpack/msgpack";
import { invokeCultNetOperation } from "cultnet-ts";
import { afterEach, describe, expect, it } from "vitest";
import { buildApp, refreshAppSession, startOAuthFlow } from "../src/app.js";
import { callerFromOpenedEnvelope, secretMatches } from "../src/app-caller.js";
import { type HeimdallConfig } from "../src/config.js";
import { entitlementFacts } from "../src/facts.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
import { sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
import { InMemoryStore } from "../src/store/index.js";
const ghostlightSecret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = [];
afterEach(async () => {
await Promise.all(resources.splice(0).reverse().map((resource) => resource.close()));
});
function testConfig(): HeimdallConfig {
return {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
privateCommandHost: "127.0.0.1",
privateCommandPort: 0,
workspaceRoot: "F:/Projects/Heimdall",
dataRoot: "F:/Projects/Heimdall/.heimdall-data",
cultCachePath: "F:/Projects/Heimdall/.heimdall-data/cultcache/test.cc",
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
providerHealthIdentityPath: "F:/Projects/Heimdall/.heimdall-data/provider-health-test.cc",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 3600,
stateTtlSeconds: 600,
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: { repixelizer: "repixelizer-secret", ghostlight: ghostlightSecret },
appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] },
appBackendCallbacks: {},
storage: { backend: "memory", applySchemaOnStartup: true },
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: {},
github: {},
twitch: {},
youtube: {},
spotify: {},
joystick: {},
},
};
}
function mockDiscordRuntime(): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return { accessToken: "provider-access", tokenType: "Bearer", scope: ["identify"], raw: {} };
},
async resolveIdentity() {
return { provider: "discord", providerUserId: "discord-victim", displayName: "Victim", profile: {} };
},
async evaluateEntitlements() {
return { facts: [entitlementFacts.appAccess], snapshots: [] };
},
};
}
describe("caller identity: unauthenticated mint paths", () => {
// Item 1 (closed). The completion code used to be chosen by whoever started
// the flow (handoff.attemptId became the completion code verbatim,
// src/app.ts createAuthCompletion `code: handoff.attemptId`), and
// auth-completions/redeem takes no app secret. An unauthenticated party
// could therefore fix the code, get a victim to finish provider login on
// the authorization URL, and redeem the victim's signed access + refresh
// tokens. The store now always mints the code itself; attemptId is kept
// only as a separate correlation field the browser may carry.
it("a client-chosen attemptId must not be redeemable for signed tokens without app auth", async () => {
const app = await buildApp({ config: testConfig(), oauthRuntimes: { discord: mockDiscordRuntime() } });
resources.push(app);
const start = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
payload: {
appSlug: "repixelizer",
mode: "sign_in",
returnTo: "https://repixelizer.gamecult.org/app/",
handoff: { kind: "browser_completion", attemptId: "attacker-fixed-code" },
},
// no x-heimdall-app-secret
});
expect(start.statusCode).toBe(201);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=victim-code&state=${encodeURIComponent(start.json().stateToken as string)}`,
headers: { accept: "text/html" },
});
expect(callback.statusCode).toBe(200);
const redeem = await app.inject({
method: "POST",
url: "/v1/apps/repixelizer/auth-completions/redeem",
payload: { completionCode: "attacker-fixed-code" },
// still no x-heimdall-app-secret
});
// The invariant the cut claims: the caller-chosen code is never minted,
// so it can never be redeemed.
expect(redeem.statusCode).not.toBe(201);
expect(redeem.json().accessToken).toBeUndefined();
});
// Item 2 (closed). returnTo used to be validated only as `format: uri`. The
// success page postMessages the completion payload to
// new URL(returnTo).origin and links to returnTo with the code in the
// fragment (src/browser-handoff.ts), so an unlisted origin was an
// exfiltration primitive. startOAuthFlow now refuses any returnTo whose
// origin is not in the app profile's allowedReturnOrigins.
it("an unauthenticated start must not accept an arbitrary returnTo origin", async () => {
const app = await buildApp({ config: testConfig(), oauthRuntimes: { discord: mockDiscordRuntime() } });
resources.push(app);
const start = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://attacker.example/collect" },
});
expect(start.statusCode).not.toBe(201);
});
it("refuses to mint on refresh when the caller supplies policy without app auth", async () => {
const app = await buildApp({ config: testConfig(), oauthRuntimes: { discord: mockDiscordRuntime() } });
resources.push(app);
const context = (app as unknown as { heimdallContext: Parameters<typeof refreshAppSession>[0] }).heimdallContext;
const policy = { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] };
const nullCaller = await refreshAppSession(context, "repixelizer", null, {
refreshToken: "irrelevant",
entitlementPolicy: policy,
} as Parameters<typeof refreshAppSession>[3]);
expect(nullCaller).toEqual({ statusCode: 401, body: { error: "app_auth_required" } });
const wrongApp = await refreshAppSession(context, "repixelizer", callerFromOpenedEnvelope("ghostlight"), {
refreshToken: "irrelevant",
entitlementPolicy: policy,
} as Parameters<typeof refreshAppSession>[3]);
expect(wrongApp).toEqual({ statusCode: 401, body: { error: "app_auth_required" } });
const nullStart = await startOAuthFlow(context, "discord", null, {
appSlug: "repixelizer",
mode: "sign_in",
returnTo: "https://repixelizer.gamecult.org/app/",
entitlementPolicy: policy,
} as Parameters<typeof startOAuthFlow>[3]);
expect(nullStart).toEqual({ statusCode: 401, body: { error: "app_auth_required" } });
});
});
describe("caller identity: single authority", () => {
// Item 2. Source tripwire: the header may be read in exactly one module,
// and the per-app secret table may be read only by the two envelope
// openers (HTTP header comparator, private-plane HMAC/AES-GCM opener).
const srcRoot = join(__dirname, "..", "src");
const sources = readdirSync(srcRoot)
.filter((name) => name.endsWith(".ts"))
.map((name) => [name, readFileSync(join(srcRoot, name), "utf8")] as const);
it("x-heimdall-app-secret is read only in app-caller.ts", () => {
const readers = sources
.filter(([, text]) => /headers\[\s*["']x-heimdall-app-secret["']\s*\]/.test(text))
.map(([name]) => name);
expect(readers).toEqual(["app-caller.ts"]);
});
it("appSharedSecrets is read only by the HTTP comparator and the private-plane envelope opener", () => {
const readers = sources
.filter(([, text]) => /appSharedSecrets\s*[\[.]/.test(text))
.map(([name]) => name)
.sort();
expect(readers).toEqual(["app-caller.ts", "private-command-plane.ts"]);
});
it("no source file compares a shared secret with ===", () => {
const offenders = sources
.filter(([, text]) => /[sS]ecret\w*\s*===|===\s*\w*[sS]ecret/.test(text))
.map(([name]) => name);
expect(offenders).toEqual([]);
});
it("AppCaller literals are constructed nowhere; AppCaller is a branded type", () => {
// Item 3 (closed). `{ appSlug }` used to satisfy AppCaller structurally,
// so the private plane (after openPrivateEnvelope + sourceRuntimeId)
// constructed the literal directly — nothing but grep discipline stopped
// any other importer from doing the same. AppCaller is now branded with
// an unexported symbol, and the private plane gets its caller from
// app-caller.ts's callerFromOpenedEnvelope, the same way the HTTP path
// gets its caller from resolveAppCaller. No file may construct the
// literal at all anymore.
const forgers = sources
.filter(([name, text]) => name !== "app-caller.ts" && /(startOAuthFlow|refreshAppSession)\([^;]*\{\s*appSlug\s*\}/s.test(text))
.map(([name]) => name);
expect(forgers).toEqual([]);
});
});
describe("caller identity: timing-safe comparator", () => {
// Item 4.
it("secretMatches rejects mismatches of any length and both empty sides", () => {
expect(secretMatches("abc", "abd")).toBe(false);
expect(secretMatches("abc", "abcd")).toBe(false);
expect(secretMatches("abc", "")).toBe(false);
expect(secretMatches(undefined, "abc")).toBe(false);
expect(secretMatches("abc", undefined)).toBe(false);
expect(secretMatches("", "")).toBe(false);
expect(secretMatches("abc", "abc")).toBe(true);
});
it("app-caller.ts is the only HTTP-side importer of timingSafeEqual", () => {
const srcRoot = join(__dirname, "..", "src");
const importers = readdirSync(srcRoot)
.filter((name) => name.endsWith(".ts"))
.filter((name) => /timingSafeEqual/.test(readFileSync(join(srcRoot, name), "utf8")))
.sort();
expect(importers).toEqual(["app-caller.ts", "private-command-security.ts"]);
});
});
describe("caller identity: private plane envelope authority", () => {
// Item 3. A correctly sealed envelope from the wrong sourceRuntimeId must
// be refused before beginAuth constructs an AppCaller: no auth attempt may
// be created.
it("refuses a valid envelope from an unconfigured runtime before any attempt is created", async () => {
const config = testConfig();
const store = new InMemoryStore();
const app = await buildApp({ config, store, oauthRuntimes: { discord: mockDiscordRuntime() } });
resources.push(app);
const plane = await startHeimdallPrivateCommandPlane(app, config);
resources.push(plane);
const envelope = sealPrivateEnvelope({
appSlug: "ghostlight",
operation: "heimdall.auth.begin",
contentSchema: "heimdall.auth_begin_command.v1",
idempotencyKey: "soul-begin-1",
secret: ghostlightSecret,
payload: {
provider: "discord",
mode: "sign_in",
returnTo: "https://yggdrasil.gamecult.org/ghostlight/",
entitlementPolicy: { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] },
},
});
const response = await invokeCultNetOperation(
plane.endpoint,
request("heimdall.auth.begin", "soul-wrong-runtime", envelope, "not-ghostlight"),
{ runtimeId: "not-ghostlight" }
);
expect(response.status).toBe("denied");
const internals = store as unknown as { authAttempts: Map<string, unknown> };
expect(internals.authAttempts.size).toBe(0);
}, 20_000);
});
function request(operation: string, messageId: string, envelope: HeimdallPrivateEnvelope, sourceRuntimeId: string) {
return {
schemaVersion: "cultnet.operation_request.v0" as const,
messageId,
serviceId: "heimdall.private.commands",
operation,
payloadSchema: "heimdall.private_command_envelope.v1",
payloadEncoding: "messagepack-base64" as const,
payload: Buffer.from(encode(envelope)).toString("base64"),
sourceRuntimeId,
};
}