feat(mosaic-as): agent registration endpoint + scoped/revocable tokens (US-007)
POST /bridge/v1/agents mints/ensures @agent-<alias>-<host> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { AppserviceDaemon } from '../server.js';
|
import { AppserviceDaemon } from '../server.js';
|
||||||
import type { DaemonConfig, DaemonRequest } from '../server.js';
|
import type { DaemonConfig, DaemonRequest } from '../server.js';
|
||||||
|
|
||||||
|
const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';
|
||||||
|
|
||||||
const cfg: DaemonConfig = {
|
const cfg: DaemonConfig = {
|
||||||
homeserverUrl: 'https://hs.example',
|
homeserverUrl: 'https://hs.example',
|
||||||
domain: 'hs.example',
|
domain: 'hs.example',
|
||||||
@@ -228,6 +230,149 @@ describe('AppserviceDaemon routing', () => {
|
|||||||
expect(bad.status).toBe(400);
|
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<string, unknown> | 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<string, unknown>;
|
||||||
|
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<string, unknown> = { 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<Record<string, unknown>>;
|
||||||
|
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 () => {
|
it('empty bridge token list denies everything', async () => {
|
||||||
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
|
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
|
||||||
const res = await daemon.handle(
|
const res = await daemon.handle(
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AgentTokenStore,
|
||||||
AppserviceIntent,
|
AppserviceIntent,
|
||||||
TransactionHandler,
|
TransactionHandler,
|
||||||
validateBridgeMessage,
|
validateBridgeMessage,
|
||||||
validateBridgeTyping,
|
validateBridgeTyping,
|
||||||
validateProvisionRoom,
|
validateProvisionRoom,
|
||||||
|
validateRegisterAgent,
|
||||||
|
validateRevokeAgent,
|
||||||
} from '@mosaicstack/appservice';
|
} from '@mosaicstack/appservice';
|
||||||
import type { AppserviceConfig, MatrixEvent } 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\/([^/]+)$/;
|
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
|
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
|
||||||
* Application Service transactions endpoint (Synapse-facing) plus the
|
* Application Service transactions endpoint (Synapse-facing) plus the
|
||||||
@@ -46,6 +56,7 @@ const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
|
|||||||
export class AppserviceDaemon {
|
export class AppserviceDaemon {
|
||||||
readonly intent: AppserviceIntent;
|
readonly intent: AppserviceIntent;
|
||||||
private readonly transactions: TransactionHandler;
|
private readonly transactions: TransactionHandler;
|
||||||
|
private readonly agents: AgentTokenStore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly cfg: DaemonConfig,
|
private readonly cfg: DaemonConfig,
|
||||||
@@ -53,6 +64,7 @@ export class AppserviceDaemon {
|
|||||||
private readonly log: (line: string) => void = (line) => console.log(line),
|
private readonly log: (line: string) => void = (line) => console.log(line),
|
||||||
) {
|
) {
|
||||||
this.intent = new AppserviceIntent(cfg, fetchImpl);
|
this.intent = new AppserviceIntent(cfg, fetchImpl);
|
||||||
|
this.agents = new AgentTokenStore(this.intent);
|
||||||
this.transactions = new TransactionHandler({
|
this.transactions = new TransactionHandler({
|
||||||
hsToken: cfg.hsToken,
|
hsToken: cfg.hsToken,
|
||||||
onEvent: (event) => this.onEvent(event),
|
onEvent: (event) => this.onEvent(event),
|
||||||
@@ -69,10 +81,20 @@ export class AppserviceDaemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bridgeAuthorized(authorizationHeader: string | undefined): boolean {
|
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
|
||||||
if (!authorizationHeader?.startsWith('Bearer ')) return false;
|
* 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<BridgePrincipal> {
|
||||||
|
if (!authorizationHeader?.startsWith('Bearer ')) return null;
|
||||||
const presented = authorizationHeader.slice('Bearer '.length);
|
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<DaemonResponse> {
|
async handle(req: DaemonRequest): Promise<DaemonResponse> {
|
||||||
@@ -89,12 +111,60 @@ export class AppserviceDaemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.path.startsWith('/bridge/v1/')) {
|
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' } };
|
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
|
||||||
}
|
}
|
||||||
try {
|
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') {
|
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
|
||||||
validateBridgeMessage(req.body);
|
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({
|
const eventId = await this.intent.sendAsAgent({
|
||||||
roomId: req.body.room_id,
|
roomId: req.body.room_id,
|
||||||
agent: req.body.agent,
|
agent: req.body.agent,
|
||||||
@@ -107,6 +177,15 @@ export class AppserviceDaemon {
|
|||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
|
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
|
||||||
validateBridgeTyping(req.body);
|
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);
|
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
|
||||||
return { status: 200, body: {} };
|
return { status: 200, body: {} };
|
||||||
}
|
}
|
||||||
|
|||||||
116
packages/appservice/src/__tests__/agent-store.test.ts
Normal file
116
packages/appservice/src/__tests__/agent-store.test.ts
Normal file
@@ -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<string, Record<string, unknown>> = {};
|
||||||
|
const fake = {
|
||||||
|
domain: 'hs.example',
|
||||||
|
getSenderAccountData: async (type: string): Promise<Record<string, unknown> | null> =>
|
||||||
|
store[type] ?? null,
|
||||||
|
setSenderAccountData: async (type: string, content: Record<string, unknown>): Promise<void> => {
|
||||||
|
store[type] = structuredClone(content);
|
||||||
|
},
|
||||||
|
ensureRegistered: async (agent: string): Promise<string> => `@agent-${agent}:hs.example`,
|
||||||
|
setDisplayName: async (): Promise<void> => {},
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
packages/appservice/src/agent-registry.dto.ts
Normal file
63
packages/appservice/src/agent-registry.dto.ts
Normal file
@@ -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<RegisterAgentDto> | 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<RevokeAgentDto> | 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,14 @@ export {
|
|||||||
validateProvisionRoom,
|
validateProvisionRoom,
|
||||||
} from './bridge.dto.js';
|
} from './bridge.dto.js';
|
||||||
export type { BridgeMessageDto, BridgeTypingDto, ProvisionRoomDto } 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 {
|
export type {
|
||||||
AppserviceConfig,
|
AppserviceConfig,
|
||||||
EventHandler,
|
EventHandler,
|
||||||
|
|||||||
@@ -233,4 +233,30 @@ export class AppserviceIntent {
|
|||||||
body: { displayname: displayName },
|
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<Record<string, unknown> | 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<string, unknown>): Promise<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user