import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; import { agentSlug } from './agent-registry.dto.js'; import type { AgentSummary } from './agent-registry.dto.js'; import type { AppserviceIntent } from './intent.js'; /** account_data type holding the agent registry on the AS sender user. */ export const AGENTS_ACCOUNT_DATA_TYPE = 'org.uscllc.mosaic_as.agents'; const TOKEN_PREFIX = 'magt_'; interface StoredAgent { alias: string; host: string; display_name?: string; created_at: string; /** sha256hex of each active token. Plaintext tokens are NEVER stored. */ token_hashes: string[]; revoked_at?: string; } interface AgentRegistry { agents: Record; } const sha256hex = (value: string): string => createHash('sha256').update(value).digest('hex'); const mintToken = (): string => `${TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`; /** * Persists scoped/revocable bridge tokens for agent virtual users in Matrix * account_data on the AS sender user (no new infra; survives restart). * * Tokens are stored only as sha256 hashes (the high-entropy `magt_` token makes * plain sha256 safe — no salt/KDF needed since brute force is infeasible). * * KNOWN v1 LIMIT: Synapse caps a single account_data object (default * max_account_data_size, ~100KB). Each agent + hash entry is small, so this * supports thousands of agents, but a very large fleet would eventually need a * dedicated store. Revoked agents with no active tokens are pruned of hashes * (kept as tombstones) to bound growth. */ export class AgentTokenStore { constructor(private readonly intent: AppserviceIntent) {} /** Read the registry fresh from account_data (low-frequency ops favor * correctness over caching; verifyToken/list also read fresh). */ private async read(): Promise { const data = await this.intent.getSenderAccountData(AGENTS_ACCOUNT_DATA_TYPE); const agents = data?.agents; if (agents && typeof agents === 'object') { return { agents: agents as Record }; } return { agents: {} }; } private async write(registry: AgentRegistry): Promise { await this.intent.setSenderAccountData(AGENTS_ACCOUNT_DATA_TYPE, { agents: registry.agents, }); } /** Ensure the virtual user exists, mint a fresh token, store its hash, and * return the plaintext token ONCE. Clears any prior revocation. */ async register(opts: { alias: string; host: string; displayName?: string; }): Promise<{ agentUserId: string; token: string }> { const slug = agentSlug(opts.alias, opts.host); const agentUserId = await this.intent.ensureRegistered(slug); if (opts.displayName !== undefined) { await this.intent.setDisplayName(slug, opts.displayName); } const token = mintToken(); const hash = sha256hex(token); const registry = await this.read(); const existing = registry.agents[agentUserId]; if (existing) { // The agent slug `-` joins with a `-`, which is also a legal // slug char, so distinct pairs can collide on one Matrix id (e.g. // a/b-c and a-b/c both -> @agent-a-b-c). They ARE the same Matrix user, // but silently overwriting the stored alias/host of a different pair // would conflate two logical agents into one token bucket. Reject the // ambiguous re-registration instead of overwriting. if (existing.alias !== opts.alias || existing.host !== opts.host) { throw new Error( `agent id collision: ${agentUserId} already registered as ` + `${existing.alias}/${existing.host}, refusing ${opts.alias}/${opts.host}`, ); } if (opts.displayName !== undefined) existing.display_name = opts.displayName; existing.token_hashes = [...existing.token_hashes, hash]; delete existing.revoked_at; } else { registry.agents[agentUserId] = { alias: opts.alias, host: opts.host, ...(opts.displayName !== undefined ? { display_name: opts.displayName } : {}), created_at: new Date().toISOString(), token_hashes: [hash], }; } await this.write(registry); return { agentUserId, token }; } /** Return the agentUserId bound to an active (non-revoked) token, else null. * Constant-time hash comparison; no early-out on match. */ async verifyToken(token: string): Promise { if (!token.startsWith(TOKEN_PREFIX)) return null; const presented = Buffer.from(sha256hex(token), 'hex'); const registry = await this.read(); let matched: string | null = null; for (const [agentUserId, agent] of Object.entries(registry.agents)) { if (agent.revoked_at) continue; for (const stored of agent.token_hashes) { const candidate = Buffer.from(stored, 'hex'); if (candidate.length === presented.length && timingSafeEqual(candidate, presented)) { // No early break: keep scanning so timing does not reveal match position. matched = agentUserId; } } } return matched; } /** Revoke all active tokens for an agent. Idempotent; returns count revoked. */ async revoke(agentUserId: string): Promise { const registry = await this.read(); const agent = registry.agents[agentUserId]; if (!agent) return 0; const count = agent.token_hashes.length; agent.token_hashes = []; agent.revoked_at = new Date().toISOString(); await this.write(registry); return count; } /** List agents with at least one active token (never advertise revoked/phantom). */ async list(): Promise { const registry = await this.read(); const out: AgentSummary[] = []; for (const [agentUserId, agent] of Object.entries(registry.agents)) { if (agent.revoked_at || agent.token_hashes.length === 0) continue; out.push({ agent_user_id: agentUserId, alias: agent.alias, host: agent.host, ...(agent.display_name !== undefined ? { display_name: agent.display_name } : {}), created_at: agent.created_at, active_token_count: agent.token_hashes.length, }); } return out; } }