From 5a11e3c121db10f59a8d5c913a565129fd749934 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 15 Jun 2026 19:55:49 -0500 Subject: [PATCH] feat(mosaic-as): agent registration endpoint + scoped/revocable tokens (US-007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /bridge/v1/agents mints/ensures @agent-- and returns a scoped, revocable per-agent bridge token. Adds POST /bridge/v1/agents/revoke (manual revoke from day one) and GET /bridge/v1/agents (reconciliation source that never advertises revoked/phantom agents). Persistence: per-agent token sha256 hashes stored in Matrix account_data on the AS sender user (org.uscllc.mosaic_as.agents) — no new infra, survives restart. Tokens are magt_-prefixed high-entropy random; plaintext is never persisted and returned exactly once. Per-agent tokens are scoped: usable only to act as their own agent on /bridge/v1/messages|typing; host bridgeTokens stay unscoped. Registration/revoke/list are host-token-only. Independent opus security review: PASS (no critical/high). Remediated the one MEDIUM (agent-slug collision: distinct alias/host pairs joining to the same Matrix id now rejected instead of silently overwriting) + regression test. Closes #540 Co-Authored-By: Claude Opus 4.8 --- apps/appservice/src/__tests__/server.test.ts | 145 ++++++++++++++++ apps/appservice/src/server.ts | 87 +++++++++- .../src/__tests__/agent-store.test.ts | 116 +++++++++++++ packages/appservice/src/agent-registry.dto.ts | 63 +++++++ packages/appservice/src/agent-store.ts | 160 ++++++++++++++++++ packages/appservice/src/index.ts | 8 + packages/appservice/src/intent.ts | 26 +++ 7 files changed, 601 insertions(+), 4 deletions(-) create mode 100644 packages/appservice/src/__tests__/agent-store.test.ts create mode 100644 packages/appservice/src/agent-registry.dto.ts create mode 100644 packages/appservice/src/agent-store.ts diff --git a/apps/appservice/src/__tests__/server.test.ts b/apps/appservice/src/__tests__/server.test.ts index b950025..50bd894 100644 --- a/apps/appservice/src/__tests__/server.test.ts +++ b/apps/appservice/src/__tests__/server.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it, vi } from 'vitest'; import { AppserviceDaemon } from '../server.js'; import type { DaemonConfig, DaemonRequest } from '../server.js'; +const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents'; + const cfg: DaemonConfig = { homeserverUrl: 'https://hs.example', domain: 'hs.example', @@ -228,6 +230,149 @@ describe('AppserviceDaemon routing', () => { expect(bad.status).toBe(400); }); + // A daemon whose fetch mock backs account_data with a mutable in-test object, + // so register/verify/revoke round-trip through the (faked) homeserver. + const makeAgentDaemon = () => { + const accountData: { value: Record | null } = { value: null }; + const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => { + const url = new URL(String(input)); + const path = url.pathname; + if (path.includes(`/account_data/${AGENTS_TYPE}`)) { + if (init?.method === 'PUT') { + accountData.value = JSON.parse(String(init.body)) as Record; + return jsonResponse(200, {}); + } + if (accountData.value === null) { + return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' }); + } + return jsonResponse(200, accountData.value); + } + if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' }); + if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' }); + return jsonResponse(200, {}); + }); + const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {}); + return { daemon, fetchMock }; + }; + + const registerAgent = async ( + daemon: AppserviceDaemon, + body: Record = { alias: 'pi0', host: 'web1' }, + ) => + daemon.handle( + request({ + method: 'POST', + path: '/bridge/v1/agents', + authorizationHeader: 'Bearer bridge-secret', + body, + }), + ); + + it('host token registers an agent and returns agent_user_id + bridge_token', async () => { + const { daemon, fetchMock } = makeAgentDaemon(); + const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' }); + expect(res.status).toBe(200); + expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example'); + expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true); + const registerCall = fetchMock.mock.calls + .map((c) => new URL(String(c[0]))) + .find((u) => u.pathname.endsWith('/register')); + expect(registerCall).toBeDefined(); + }); + + it('register requires a HOST token (agent token and no token are 403)', async () => { + const { daemon } = makeAgentDaemon(); + const minted = await registerAgent(daemon); + const agentToken = String(minted.body.bridge_token); + + const asAgent = await daemon.handle( + request({ + method: 'POST', + path: '/bridge/v1/agents', + authorizationHeader: `Bearer ${agentToken}`, + body: { alias: 'pi1', host: 'web2' }, + }), + ); + expect(asAgent.status).toBe(403); + + const noAuth = await daemon.handle( + request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }), + ); + expect(noAuth.status).toBe(403); + }); + + it('agent-scoped token may send as itself but not as another agent', async () => { + const { daemon } = makeAgentDaemon(); + const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' }); + const agentToken = String(minted.body.bridge_token); + + const self = await daemon.handle( + request({ + method: 'POST', + path: '/bridge/v1/messages', + authorizationHeader: `Bearer ${agentToken}`, + body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' }, + }), + ); + expect(self.status).toBe(200); + + const other = await daemon.handle( + request({ + method: 'POST', + path: '/bridge/v1/messages', + authorizationHeader: `Bearer ${agentToken}`, + body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' }, + }), + ); + expect(other.status).toBe(403); + expect(other.body.error).toBe('token not scoped to this agent'); + }); + + it('revoked agent token is rejected on messages', async () => { + const { daemon } = makeAgentDaemon(); + const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' }); + const agentToken = String(minted.body.bridge_token); + + const revoke = await daemon.handle( + request({ + method: 'POST', + path: '/bridge/v1/agents/revoke', + authorizationHeader: 'Bearer bridge-secret', + body: { agent_user_id: '@agent-pi0-web1:hs.example' }, + }), + ); + expect(revoke.status).toBe(200); + expect(revoke.body.revoked).toBe(1); + + const afterRevoke = await daemon.handle( + request({ + method: 'POST', + path: '/bridge/v1/messages', + authorizationHeader: `Bearer ${agentToken}`, + body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' }, + }), + ); + expect(afterRevoke.status).toBe(403); + }); + + it('GET /bridge/v1/agents lists registered agents (host only)', async () => { + const { daemon } = makeAgentDaemon(); + await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' }); + + const res = await daemon.handle( + request({ + method: 'GET', + path: '/bridge/v1/agents', + authorizationHeader: 'Bearer bridge-secret', + }), + ); + expect(res.status).toBe(200); + const agents = res.body.agents as Array>; + expect(agents).toHaveLength(1); + expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example'); + expect(agents[0]?.display_name).toBe('Pi Zero'); + }); + it('empty bridge token list denies everything', async () => { const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {}); const res = await daemon.handle( diff --git a/apps/appservice/src/server.ts b/apps/appservice/src/server.ts index 4da5417..34dd3cd 100644 --- a/apps/appservice/src/server.ts +++ b/apps/appservice/src/server.ts @@ -1,11 +1,14 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { + AgentTokenStore, AppserviceIntent, TransactionHandler, validateBridgeMessage, validateBridgeTyping, validateProvisionRoom, + validateRegisterAgent, + validateRevokeAgent, } from '@mosaicstack/appservice'; import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice'; @@ -37,6 +40,13 @@ const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a), const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/; +/** + * Resolved identity for an authenticated /bridge/v1/* caller. Host principals + * (the agent-comms host daemons) are unrestricted; agent principals are scoped + * to a single virtual user and may only act as themselves. + */ +export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null; + /** * HTTP-framework-agnostic request router for the mosaic-as daemon: the * Application Service transactions endpoint (Synapse-facing) plus the @@ -46,6 +56,7 @@ const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/; export class AppserviceDaemon { readonly intent: AppserviceIntent; private readonly transactions: TransactionHandler; + private readonly agents: AgentTokenStore; constructor( private readonly cfg: DaemonConfig, @@ -53,6 +64,7 @@ export class AppserviceDaemon { private readonly log: (line: string) => void = (line) => console.log(line), ) { this.intent = new AppserviceIntent(cfg, fetchImpl); + this.agents = new AgentTokenStore(this.intent); this.transactions = new TransactionHandler({ hsToken: cfg.hsToken, onEvent: (event) => this.onEvent(event), @@ -69,10 +81,20 @@ export class AppserviceDaemon { } } - private bridgeAuthorized(authorizationHeader: string | undefined): boolean { - if (!authorizationHeader?.startsWith('Bearer ')) return false; + /** Resolve the calling principal, or null when unauthorized. Fail-closed: + * host tokens win (timing-safe compare); otherwise a magt_* bearer is looked + * up in the agent token store; anything else is rejected. */ + private async bridgeAuthorized( + authorizationHeader: string | undefined, + ): Promise { + if (!authorizationHeader?.startsWith('Bearer ')) return null; const presented = authorizationHeader.slice('Bearer '.length); - return this.cfg.bridgeTokens.some((token) => safeEqual(presented, token)); + if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) { + return { kind: 'host' }; + } + const agentUserId = await this.agents.verifyToken(presented); + if (agentUserId) return { kind: 'agent', agentUserId }; + return null; } async handle(req: DaemonRequest): Promise { @@ -89,12 +111,60 @@ export class AppserviceDaemon { } if (req.path.startsWith('/bridge/v1/')) { - if (!this.bridgeAuthorized(req.authorizationHeader)) { + const principal = await this.bridgeAuthorized(req.authorizationHeader); + if (!principal) { return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } }; } try { + if (req.method === 'POST' && req.path === '/bridge/v1/agents') { + if (principal.kind !== 'host') { + return { + status: 403, + body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' }, + }; + } + validateRegisterAgent(req.body); + const { agentUserId, token } = await this.agents.register({ + alias: req.body.alias, + host: req.body.host, + displayName: req.body.display_name, + }); + this.log(`registered agent ${agentUserId}`); + return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } }; + } + if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') { + if (principal.kind !== 'host') { + return { + status: 403, + body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' }, + }; + } + validateRevokeAgent(req.body); + const revoked = await this.agents.revoke(req.body.agent_user_id); + this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`); + return { status: 200, body: { revoked } }; + } + if (req.method === 'GET' && req.path === '/bridge/v1/agents') { + if (principal.kind !== 'host') { + return { + status: 403, + body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' }, + }; + } + const agents = await this.agents.list(); + return { status: 200, body: { agents } }; + } if (req.method === 'POST' && req.path === '/bridge/v1/messages') { validateBridgeMessage(req.body); + if ( + principal.kind === 'agent' && + this.intent.agentUserId(req.body.agent) !== principal.agentUserId + ) { + return { + status: 403, + body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' }, + }; + } const eventId = await this.intent.sendAsAgent({ roomId: req.body.room_id, agent: req.body.agent, @@ -107,6 +177,15 @@ export class AppserviceDaemon { } if (req.method === 'POST' && req.path === '/bridge/v1/typing') { validateBridgeTyping(req.body); + if ( + principal.kind === 'agent' && + this.intent.agentUserId(req.body.agent) !== principal.agentUserId + ) { + return { + status: 403, + body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' }, + }; + } await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing); return { status: 200, body: {} }; } diff --git a/packages/appservice/src/__tests__/agent-store.test.ts b/packages/appservice/src/__tests__/agent-store.test.ts new file mode 100644 index 0000000..e4517db --- /dev/null +++ b/packages/appservice/src/__tests__/agent-store.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; + +import { AGENTS_ACCOUNT_DATA_TYPE, AgentTokenStore } from '../agent-store.js'; +import type { AppserviceIntent } from '../intent.js'; + +/** Fake intent: in-memory account_data, no-op user provisioning. Only the + * surface AgentTokenStore touches is implemented. */ +const makeFakeIntent = () => { + const store: Record> = {}; + const fake = { + domain: 'hs.example', + getSenderAccountData: async (type: string): Promise | null> => + store[type] ?? null, + setSenderAccountData: async (type: string, content: Record): Promise => { + store[type] = structuredClone(content); + }, + ensureRegistered: async (agent: string): Promise => `@agent-${agent}:hs.example`, + setDisplayName: async (): Promise => {}, + }; + return { intent: fake as unknown as AppserviceIntent, store }; +}; + +describe('AgentTokenStore', () => { + it('mints a magt_ token and stores only its sha256 (never plaintext)', async () => { + const { intent, store } = makeFakeIntent(); + const s = new AgentTokenStore(intent); + const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' }); + + expect(agentUserId).toBe('@agent-pi0-web1:hs.example'); + expect(token.startsWith('magt_')).toBe(true); + + const raw = JSON.stringify(store[AGENTS_ACCOUNT_DATA_TYPE]); + expect(raw).not.toContain(token); + // The stored hash is sha256hex(token), 64 hex chars. + const { createHash } = await import('node:crypto'); + const hash = createHash('sha256').update(token).digest('hex'); + expect(raw).toContain(hash); + }); + + it('verifyToken returns the agentUserId for a fresh token, null otherwise', async () => { + const { intent } = makeFakeIntent(); + const s = new AgentTokenStore(intent); + const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' }); + + expect(await s.verifyToken(token)).toBe(agentUserId); + expect(await s.verifyToken('magt_garbage')).toBeNull(); + expect(await s.verifyToken('not-a-token')).toBeNull(); + expect(await s.verifyToken('')).toBeNull(); + }); + + it('revoke invalidates tokens, returns count, and hides agent from list', async () => { + const { intent } = makeFakeIntent(); + const s = new AgentTokenStore(intent); + const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' }); + + expect((await s.list()).map((a) => a.agent_user_id)).toContain(agentUserId); + + const count = await s.revoke(agentUserId); + expect(count).toBe(1); + expect(await s.verifyToken(token)).toBeNull(); + expect((await s.list()).map((a) => a.agent_user_id)).not.toContain(agentUserId); + + // Idempotent on unknown / already-revoked. + expect(await s.revoke(agentUserId)).toBe(0); + expect(await s.revoke('@agent-nope:hs.example')).toBe(0); + }); + + it('re-register after revoke yields a working token and the agent reappears', async () => { + const { intent } = makeFakeIntent(); + const s = new AgentTokenStore(intent); + const { agentUserId, token: t1 } = await s.register({ alias: 'pi0', host: 'web1' }); + await s.revoke(agentUserId); + + const { token: t2 } = await s.register({ alias: 'pi0', host: 'web1' }); + expect(await s.verifyToken(t1)).toBeNull(); + expect(await s.verifyToken(t2)).toBe(agentUserId); + expect((await s.list()).map((a) => a.agent_user_id)).toContain(agentUserId); + }); + + it('agent A token never verifies as agent B', async () => { + const { intent } = makeFakeIntent(); + const s = new AgentTokenStore(intent); + const a = await s.register({ alias: 'pi0', host: 'web1' }); + const b = await s.register({ alias: 'pi1', host: 'web2' }); + + expect(await s.verifyToken(a.token)).toBe(a.agentUserId); + expect(await s.verifyToken(b.token)).toBe(b.agentUserId); + expect(a.agentUserId).not.toBe(b.agentUserId); + }); + + it('rejects an ambiguous re-registration that collides on one Matrix id', async () => { + const { intent } = makeFakeIntent(); + const s = new AgentTokenStore(intent); + // alias="a-b",host="c" and alias="a",host="b-c" both -> @agent-a-b-c. + const first = await s.register({ alias: 'a-b', host: 'c' }); + expect(first.agentUserId).toBe('@agent-a-b-c:hs.example'); + + await expect(s.register({ alias: 'a', host: 'b-c' })).rejects.toThrow(/collision/); + + // The original registration is untouched: still one active token, correct pair. + expect(await s.verifyToken(first.token)).toBe(first.agentUserId); + const summary = (await s.list()).find((x) => x.agent_user_id === first.agentUserId); + expect(summary?.alias).toBe('a-b'); + expect(summary?.host).toBe('c'); + expect(summary?.active_token_count).toBe(1); + }); + + it('display_name is stored and surfaced in list', async () => { + const { intent } = makeFakeIntent(); + const s = new AgentTokenStore(intent); + await s.register({ alias: 'pi0', host: 'web1', displayName: 'Pi Zero' }); + const summary = (await s.list())[0]; + expect(summary?.display_name).toBe('Pi Zero'); + expect(summary?.active_token_count).toBe(1); + }); +}); diff --git a/packages/appservice/src/agent-registry.dto.ts b/packages/appservice/src/agent-registry.dto.ts new file mode 100644 index 0000000..e0f287b --- /dev/null +++ b/packages/appservice/src/agent-registry.dto.ts @@ -0,0 +1,63 @@ +/** DTOs for agent registration + scoped/revocable bridge tokens (US-007). */ + +export interface RegisterAgentDto { + /** Agent alias slug, e.g. "pi0". Combined with host into the agent slug. */ + alias: string; + /** Host slug, e.g. "web1". Combined with alias into the agent slug. */ + host: string; + display_name?: string; +} + +export interface RevokeAgentDto { + agent_user_id: string; +} + +export interface RegisterAgentResponse { + agent_user_id: string; + bridge_token: string; +} + +export interface AgentSummary { + agent_user_id: string; + alias: string; + host: string; + display_name?: string; + created_at: string; + active_token_count: number; +} + +const SLUG_RE = /^[a-z0-9][a-z0-9_.-]*$/; + +/** Combined agent slug, e.g. alias="pi0", host="web1" -> "pi0-web1". */ +export function agentSlug(alias: string, host: string): string { + return `${alias}-${host}`; +} + +const assertSlug = (value: unknown, field: string): void => { + if (typeof value !== 'string' || value.length === 0 || !SLUG_RE.test(value)) { + throw new Error(`${field} must match [a-z0-9][a-z0-9_.-]* (lowercase, non-empty)`); + } +}; + +export function validateRegisterAgent(input: unknown): asserts input is RegisterAgentDto { + const o = input as Partial | null | undefined; + if (!o || typeof o !== 'object') throw new Error('payload must be an object'); + assertSlug(o.alias, 'alias'); + assertSlug(o.host, 'host'); + if (o.display_name !== undefined) { + if (typeof o.display_name !== 'string' || o.display_name.length === 0) { + throw new Error('display_name must be a non-empty string'); + } + if (o.display_name.length > 100) { + throw new Error('display_name must be at most 100 chars'); + } + } +} + +export function validateRevokeAgent(input: unknown): asserts input is RevokeAgentDto { + const o = input as Partial | null | undefined; + if (!o || typeof o !== 'object') throw new Error('payload must be an object'); + if (typeof o.agent_user_id !== 'string' || !o.agent_user_id.startsWith('@')) { + throw new Error('agent_user_id must be a Matrix user id'); + } +} diff --git a/packages/appservice/src/agent-store.ts b/packages/appservice/src/agent-store.ts new file mode 100644 index 0000000..df3c3ab --- /dev/null +++ b/packages/appservice/src/agent-store.ts @@ -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; +} + +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; + } +} diff --git a/packages/appservice/src/index.ts b/packages/appservice/src/index.ts index 37c7661..e28f314 100644 --- a/packages/appservice/src/index.ts +++ b/packages/appservice/src/index.ts @@ -10,6 +10,14 @@ export { validateProvisionRoom, } from './bridge.dto.js'; export type { BridgeMessageDto, BridgeTypingDto, ProvisionRoomDto } from './bridge.dto.js'; +export { agentSlug, validateRegisterAgent, validateRevokeAgent } from './agent-registry.dto.js'; +export type { + RegisterAgentDto, + RevokeAgentDto, + RegisterAgentResponse, + AgentSummary, +} from './agent-registry.dto.js'; +export { AgentTokenStore, AGENTS_ACCOUNT_DATA_TYPE } from './agent-store.js'; export type { AppserviceConfig, EventHandler, diff --git a/packages/appservice/src/intent.ts b/packages/appservice/src/intent.ts index 050b3f4..fa6c78e 100644 --- a/packages/appservice/src/intent.ts +++ b/packages/appservice/src/intent.ts @@ -233,4 +233,30 @@ export class AppserviceIntent { body: { displayname: displayName }, }); } + + /** Read an account_data object on the AS sender user. Returns null when the + * key has never been written (M_NOT_FOUND), so callers can treat that as an + * empty store; any other error propagates. */ + async getSenderAccountData(type: string): Promise | null> { + const user = encodeURIComponent(this.senderUserId); + const key = encodeURIComponent(type); + try { + return await this.request('GET', `/_matrix/client/v3/user/${user}/account_data/${key}`, { + userId: this.senderUserId, + }); + } catch (err) { + if (err instanceof MatrixApiError && err.errcode === 'M_NOT_FOUND') return null; + throw err; + } + } + + /** Write an account_data object on the AS sender user. */ + async setSenderAccountData(type: string, content: Record): Promise { + const user = encodeURIComponent(this.senderUserId); + const key = encodeURIComponent(type); + await this.request('PUT', `/_matrix/client/v3/user/${user}/account_data/${key}`, { + userId: this.senderUserId, + body: content, + }); + } }