Compare commits
1 Commits
feat/frame
...
feat/us007
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a11e3c121 |
@@ -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<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 () => {
|
||||
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
|
||||
const res = await daemon.handle(
|
||||
|
||||
@@ -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<BridgePrincipal> {
|
||||
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<DaemonResponse> {
|
||||
@@ -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: {} };
|
||||
}
|
||||
|
||||
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,
|
||||
} 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,
|
||||
|
||||
@@ -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<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