Heimdall/src/private-command-plane.ts
Meta Vens 963150f537 Audit events never carry a completion code
Soul pass 5, S5-3: auth_completion_created stored completion.code, which
the unauthenticated redeem route turns into the session until it expires
(300 s by default), so the audit trail could redeem a live sign-in. The
two auth_completion_redeemed events (the HTTP redeem route and the private
command plane's redeem by attempt) stored the spent code as well.

All three payloads drop it. Nothing reads audit payloads back: the store
has no audit read, and no repository indexed by VoidBot consumes these
events. The event's sessionId already names the completion (one per
callback), so no replacement key is added. This matches Heimdall's own
witness contract, which says codes are redacted from audit exports.

Tests drive a sign-in through the HTTP redeem route and through the plane,
pin each payload exactly, and check the code appears in no audit event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:56:35 +02:00

428 lines
20 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { decode, encode } from "@msgpack/msgpack";
import {
startCultNetOperationServer,
type CultNetOperationRequestMessage,
type CultNetOperationResponseMessage,
type CultNetOperationServer,
} from "cultnet-ts";
import { type FastifyInstance } from "fastify";
import { errorIdentity, getHeimdallRuntimeContext, refreshAppSession, startOAuthFlow, verifyRefreshToken } from "./app.js";
import { callerFromOpenedEnvelope } from "./app-caller.js";
import { getAppProfile } from "./app-profiles.js";
import { executeHeimdallAccessPlugin, HEIMDALL_ACCESS_PLUGIN_ID, type EvePluginAbiRequest } from "./access-plugin.js";
import { isAppSlug, oauthModes, providers, type AppSlug, type OAuthEntitlementPolicy, type OAuthMode, type Provider } from "./contracts.js";
import { type HeimdallConfig } from "./config.js";
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "./private-command-security.js";
export const HEIMDALL_PRIVATE_COMMAND_SERVICE = "heimdall.private.commands";
export const HEIMDALL_PRIVATE_ENVELOPE_SCHEMA = "heimdall.private_command_envelope.v1";
interface CachedResult {
fingerprint: string;
status: string;
envelope: HeimdallPrivateEnvelope;
}
export async function startHeimdallPrivateCommandPlane(
app: FastifyInstance,
config: HeimdallConfig,
): Promise<CultNetOperationServer> {
const seenNonces = new Map<string, number>();
const inFlight = new Map<string, Promise<CachedResult>>();
return await startCultNetOperationServer({
runtimeId: config.daemonId,
host: config.privateCommandHost ?? "127.0.0.1",
port: config.privateCommandPort ?? 4101,
handler: async (request) => {
const now = Date.now();
for (const [nonce, expiry] of seenNonces) if (expiry <= now) seenNonces.delete(nonce);
try {
if (request.serviceId === HEIMDALL_ACCESS_PLUGIN_ID) {
if (request.payloadSchema !== "gamecult.eve.plugin_abi.request.v1" || request.payloadEncoding !== "messagepack-base64") {
throw new Error("Heimdall access plugin requires the Eve plugin ABI request schema.");
}
const pluginRequest = decode(Buffer.from(request.payload, "base64")) as EvePluginAbiRequest;
if (pluginRequest.operation !== request.operation) throw new Error("Plugin ABI operation disagrees with CultNet routing.");
const pluginResponse = executeHeimdallAccessPlugin(pluginRequest);
return {
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: request.serviceId,
operation: request.operation,
status: pluginResponse.status,
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode(pluginResponse)).toString("base64"),
diagnostics: [],
sourceRuntimeId: config.daemonId,
};
}
if (request.serviceId !== HEIMDALL_PRIVATE_COMMAND_SERVICE) throw new Error("Unknown Heimdall private command service.");
if (request.payloadSchema !== HEIMDALL_PRIVATE_ENVELOPE_SCHEMA || request.payloadEncoding !== "messagepack-base64") {
throw new Error("Heimdall private commands require the typed encrypted MessagePack envelope.");
}
const envelope = decode(Buffer.from(request.payload, "base64")) as HeimdallPrivateEnvelope;
if (!isAppSlug(envelope.appSlug)) throw new Error("Unknown Heimdall app binding.");
const appSlug = envelope.appSlug as AppSlug;
const secret = config.appSharedSecrets[appSlug];
if (!secret) throw new Error("Heimdall app binding has no private command secret.");
if (envelope.operation !== request.operation) throw new Error("Private envelope operation disagrees with CultNet routing.");
const expectedContentSchema = request.operation === "heimdall.auth.begin"
? "heimdall.auth_begin_command.v1"
: request.operation === "heimdall.auth.complete"
? "heimdall.auth_complete_command.v1"
: request.operation === "heimdall.auth.refresh"
? "heimdall.auth_refresh_command.v1"
: request.operation === "heimdall.auth.logout"
? "heimdall.auth_logout_command.v1"
: undefined;
if (!expectedContentSchema || envelope.contentSchema !== expectedContentSchema) {
throw new Error("Private command content schema does not match its operation.");
}
const allowedRuntimeIds = config.appRuntimeIds?.[appSlug] ?? [`yggdrasil-${appSlug}`];
if (!request.sourceRuntimeId || !allowedRuntimeIds.includes(request.sourceRuntimeId)) {
throw new Error("Private command caller is not the configured app runtime.");
}
const payload = openPrivateEnvelope(envelope, secret, now);
const fingerprint = createHash("sha256").update(request.payload).digest("hex");
const resultKey = `${appSlug}:${envelope.idempotencyKey}`;
const store = getHeimdallRuntimeContext(app).store;
const persisted = await store.findPrivateCommandReceipt(appSlug, envelope.idempotencyKey);
if (persisted) {
if (persisted.requestFingerprint !== fingerprint) throw new Error("Idempotency key was reused with different command content.");
const cachedEnvelope = decode(Buffer.from(persisted.envelopeBase64, "base64")) as HeimdallPrivateEnvelope;
return response(request, persisted.status, cachedEnvelope, config.daemonId);
}
const pending = inFlight.get(resultKey);
if (pending) {
const cached = await pending;
if (cached.fingerprint !== fingerprint) throw new Error("Idempotency key was reused with different command content.");
return response(request, cached.status, cached.envelope, config.daemonId);
}
if (seenNonces.has(`${appSlug}:${envelope.nonce}`)) throw new Error("Private command nonce was already used.");
seenNonces.set(`${appSlug}:${envelope.nonce}`, Date.parse(envelope.expiresAt));
const execution = (async (): Promise<CachedResult> => {
const output = await executePrivateCommand(app, config, appSlug, request.operation, payload);
const sealed = sealPrivateEnvelope({
appSlug,
operation: request.operation,
contentSchema: output.payloadSchema,
idempotencyKey: envelope.idempotencyKey,
secret,
payload: output.payload,
});
const stored = await store.createPrivateCommandReceipt({
appSlug,
idempotencyKey: envelope.idempotencyKey,
requestFingerprint: fingerprint,
status: output.status,
contentSchema: output.payloadSchema,
envelopeBase64: Buffer.from(encode(sealed)).toString("base64"),
createdAt: sealed.issuedAt,
expiresAt: sealed.expiresAt,
});
return {
fingerprint: stored.requestFingerprint,
status: stored.status,
envelope: decode(Buffer.from(stored.envelopeBase64, "base64")) as HeimdallPrivateEnvelope,
};
})();
inFlight.set(resultKey, execution);
let cachedResult: CachedResult;
try {
cachedResult = await execution;
} finally {
if (inFlight.get(resultKey) === execution) inFlight.delete(resultKey);
}
return response(request, cachedResult.status, cachedResult.envelope, config.daemonId);
} catch (error) {
// The reply's diagnostics travel outside the sealed envelope, and the
// caller logs them. Errors caught here carry input: JSON.parse quotes
// a provider's body, a store error names the database. So the
// diagnostic is fixed text plus the error's class and code; anything
// more lives only in a sealed payload the plane authored.
const { errorClass, errorCode } = errorIdentity(error);
return {
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: request.serviceId,
operation: request.operation,
status: "denied",
payloadSchema: "gamecult.cultnet.operation_failure.v1",
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode({ code: "private-command-denied", message: "Heimdall denied the private command." })).toString("base64"),
diagnostics: [`Heimdall denied the private command (${errorClass}${errorCode ? `, ${errorCode}` : ""}).`],
sourceRuntimeId: config.daemonId,
};
}
},
});
}
async function executePrivateCommand(
app: FastifyInstance,
config: HeimdallConfig,
appSlug: AppSlug,
operation: string,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
if (operation === "heimdall.auth.begin") return await beginAuth(app, config, appSlug, payload);
if (operation === "heimdall.auth.complete") return await completeAuth(app, appSlug, payload);
if (operation === "heimdall.auth.refresh") return await refreshAuth(app, appSlug, payload);
if (operation === "heimdall.auth.logout") return await logoutAuth(app, config, appSlug, payload);
throw new Error(`Unsupported Heimdall private operation '${operation}'.`);
}
async function logoutAuth(
app: FastifyInstance,
config: HeimdallConfig,
appSlug: AppSlug,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
const refreshToken = String(payload.refreshToken ?? "");
if (!refreshToken) throw new Error("Auth logout requires the app's encrypted refresh claim.");
const context = getHeimdallRuntimeContext(app);
const claim = verifyRefreshToken(refreshToken, appSlug, config, context.keys);
if (!claim) throw new Error("Auth logout received an invalid or expired refresh claim.");
const revokedAt = new Date().toISOString();
const session = await context.store.revokeSession(
appSlug,
claim.sid,
claim.account_id,
claim.access_revision,
revokedAt,
);
if (!session || session.accessRevision !== claim.access_revision + 1) {
throw new Error("Auth logout could not revoke the exact session custody claim.");
}
await context.store.createAuditEvent({
accountId: claim.account_id,
sessionId: claim.sid,
appSlug,
eventType: "session_revoked",
eventPayloadJson: { previousAccessRevision: claim.access_revision, accessRevision: session.accessRevision },
createdAt: revokedAt,
});
return {
status: "accepted",
payloadSchema: "heimdall.auth_logout_receipt.v1",
payload: {
schema: "heimdall.auth_logout_receipt.v1",
status: "revoked",
sessionId: claim.sid,
accessRevision: session.accessRevision,
revokedAt,
},
};
}
async function refreshAuth(
app: FastifyInstance,
appSlug: AppSlug,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
const refreshToken = String(payload.refreshToken ?? "");
if (!refreshToken) throw new Error("Auth refresh requires the app's encrypted refresh claim.");
const entitlementPolicy = parseEntitlementPolicy(payload.entitlementPolicy);
// The plane already authenticated this call as `appSlug` by opening the
// envelope with that app's shared secret and checking sourceRuntimeId; it
// gets its AppCaller from the one other constructor that authority owns
// (app-caller.ts) instead of writing the literal here, and calls the same
// handler function the HTTP route uses rather than forging the HTTP header
// back at itself.
const context = getHeimdallRuntimeContext(app);
await requireEntitlementPolicyIfProfileDemandsIt(appSlug, entitlementPolicy, "refresh");
const result = await refreshAppSession(context, appSlug, callerFromOpenedEnvelope(appSlug), {
refreshToken,
...(entitlementPolicy ? { entitlementPolicy } : {}),
});
const refreshedPayload = result.body as Record<string, unknown>;
if (result.statusCode !== 201) {
return {
status: "accepted",
payloadSchema: "heimdall.auth_refresh_receipt.v1",
payload: {
schema: "heimdall.auth_refresh_receipt.v1",
status: "denied",
error: String(refreshedPayload.error ?? "refresh_denied"),
},
};
}
return {
status: "accepted",
payloadSchema: "heimdall.auth_refresh_receipt.v1",
payload: { ...refreshedPayload, schema: "heimdall.auth_refresh_receipt.v1", status: "authenticated" },
};
}
async function beginAuth(
app: FastifyInstance,
config: HeimdallConfig,
appSlug: AppSlug,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
const provider = String(payload.provider ?? "discord") as Provider;
const mode = String(payload.mode ?? "sign_in") as OAuthMode;
const returnTo = String(payload.returnTo ?? "");
if (!providers.includes(provider) || !oauthModes.includes(mode) || !returnTo) throw new Error("Auth begin payload is incomplete.");
const entitlementPolicy = parseEntitlementPolicy(payload.entitlementPolicy);
const store = getHeimdallRuntimeContext(app).store;
await requireEntitlementPolicyIfProfileDemandsIt(appSlug, entitlementPolicy, "authentication");
const now = new Date();
const attempt = await store.createAuthAttempt({
handle: randomUUID(),
appSlug,
provider,
mode,
returnTo,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + config.stateTtlSeconds * 1000).toISOString(),
});
// Same authority, same non-HTTP entry as refreshAuth above.
const context = getHeimdallRuntimeContext(app);
const start = await startOAuthFlow(
{ config: context.config, keys: context.keys, store: context.store },
provider,
callerFromOpenedEnvelope(appSlug),
{
appSlug,
mode,
returnTo,
handoff: { kind: "browser_completion" },
...(entitlementPolicy ? { entitlementPolicy } : {}),
},
{ trustedBrowserAttemptId: attempt.handle }
);
const startPayload = start.body as Record<string, unknown>;
if (start.statusCode !== 201) {
await context.store.updateAuthAttempt(appSlug, attempt.handle, {
status: "denied",
at: new Date().toISOString(),
denialCode: String(startPayload.error ?? "oauth_start_denied"),
});
throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied."));
}
const authorizationUrl = String(startPayload.authorizationUrl ?? "");
const authorizationOrigin = new URL(authorizationUrl).origin;
return {
status: "accepted",
payloadSchema: "heimdall.auth_begin_receipt.v1",
payload: {
schema: "heimdall.auth_begin_receipt.v1",
status: "pending",
handle: attempt.handle,
expiresAt: attempt.expiresAt,
navigation: {
url: authorizationUrl,
allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin],
},
},
};
}
async function completeAuth(
app: FastifyInstance,
appSlug: AppSlug,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
const handle = String(payload.handle ?? "");
if (!handle) throw new Error("Auth completion requires an opaque attempt handle.");
const store = getHeimdallRuntimeContext(app).store;
const attempt = await store.findAuthAttempt(appSlug, handle);
if (!attempt) throw new Error("Auth attempt is unknown for this app.");
const now = new Date().toISOString();
if (attempt.expiresAt <= now && attempt.status !== "completed") {
await store.updateAuthAttempt(appSlug, handle, { status: "expired", at: now });
return authCompletion("denied", { handle, error: "expired_attempt" });
}
if (attempt.status === "pending") return authCompletion("pending", { handle });
if (attempt.status !== "completed") {
return authCompletion("denied", { handle, error: attempt.denialCode ?? `attempt_${attempt.status}` });
}
// Consume by the attempt handle directly rather than forging an HTTP
// self-call into the public redeem route: the plane already authenticated
// `appSlug`, and the completion code is a secret this process never needs
// to see, let alone pass back through its own front door.
const completion = await store.consumeAuthCompletionByAttempt(appSlug, handle, now);
if (!completion) {
return authCompletion("denied", { handle, error: "invalid_or_expired_completion" });
}
await store.createAuditEvent({
accountId: completion.accountId,
sessionId: completion.sessionId,
appSlug: completion.appSlug,
eventType: "auth_completion_redeemed",
eventPayloadJson: {
provider: completion.provider,
mode: completion.mode,
},
createdAt: now,
});
await store.updateAuthAttempt(appSlug, handle, { status: "consumed", at: now });
return authCompletion("authenticated", { handle, ...completion.payloadJson });
}
function authCompletion(status: string, values: Record<string, unknown>) {
return {
// CultNet reports whether the private command executed. Authentication state
// remains inside the typed receipt so callers can decode pending and denied
// outcomes instead of mistaking them for transport failures.
status: "accepted",
payloadSchema: "heimdall.auth_completion_receipt.v1",
payload: { ...values, schema: "heimdall.auth_completion_receipt.v1", status },
};
}
function parseEntitlementPolicy(value: unknown): OAuthEntitlementPolicy | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
if (record.kind === "discord_role_access" && typeof record.guildId === "string" && Array.isArray(record.allowedRoleIds)) {
const allowedRoleIds = record.allowedRoleIds.filter((item): item is string => typeof item === "string" && Boolean(item));
return allowedRoleIds.length ? { kind: "discord_role_access", guildId: record.guildId, allowedRoleIds } : undefined;
}
if (record.kind === "patreon_membership_access" && typeof record.requiredTierTitle === "string") {
return { kind: "patreon_membership_access", requiredTierTitle: record.requiredTierTitle };
}
return undefined;
}
/**
* Ghostlight requires its caller-owned Discord role policy on every begin and
* refresh; that used to be a literal `appSlug === "ghostlight"` branch here.
* The requirement is now data on the app's profile
* (AppProfile.requiredEntitlementPolicyKind), so a future app with the same
* need declares it in its profile instead of adding another branch.
*/
async function requireEntitlementPolicyIfProfileDemandsIt(
appSlug: AppSlug,
entitlementPolicy: OAuthEntitlementPolicy | undefined,
action: "authentication" | "refresh",
): Promise<void> {
const profile = getAppProfile(appSlug);
const requiredKind = profile?.requiredEntitlementPolicyKind;
if (requiredKind && entitlementPolicy?.kind !== requiredKind) {
throw new Error(`${appSlug} ${action} requires its caller-owned ${requiredKind} policy.`);
}
}
function response(
request: CultNetOperationRequestMessage,
status: string,
envelope: HeimdallPrivateEnvelope,
runtimeId: string,
): CultNetOperationResponseMessage {
return {
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: request.serviceId,
operation: request.operation,
status,
payloadSchema: HEIMDALL_PRIVATE_ENVELOPE_SCHEMA,
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode(envelope)).toString("base64"),
diagnostics: [],
sourceRuntimeId: runtimeId,
};
}