feat(mosaic-as): agent registration + scoped/revocable tokens (US-007) (#541)
This commit was merged in pull request #541.
This commit is contained in:
160
packages/appservice/src/agent-store.ts
Normal file
160
packages/appservice/src/agent-store.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
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<string, StoredAgent>;
|
||||
}
|
||||
|
||||
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<AgentRegistry> {
|
||||
const data = await this.intent.getSenderAccountData(AGENTS_ACCOUNT_DATA_TYPE);
|
||||
const agents = data?.agents;
|
||||
if (agents && typeof agents === 'object') {
|
||||
return { agents: agents as Record<string, StoredAgent> };
|
||||
}
|
||||
return { agents: {} };
|
||||
}
|
||||
|
||||
private async write(registry: AgentRegistry): Promise<void> {
|
||||
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 `<alias>-<host>` 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<string | null> {
|
||||
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<number> {
|
||||
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<AgentSummary[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user