The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 68f53f7907caCommit detailsView latest

packages/persistence/src/connector-claim.ts 148 lines · 4.8 KB · typescript
1
import {
2
  createHash,
3
  createPublicKey,
4
  publicEncrypt,
5
  randomBytes,
6
  randomUUID,
7
  timingSafeEqual,
8
} from 'node:crypto';
9
import {
10
  ConnectorAgentProfileSchema,
11
  type ConnectorAgentProfile,
12
} from '../../protocol/src/index.js';
13
import {
14
  getOnboardingState,
15
  type AgentProfile,
16
  type OnboardingStore,
17
  type Project,
18
} from './onboarding.js';
19
20
export type ConnectorClaimStatus = 'pending' | 'approved' | 'rejected' | 'connected' | 'expired';
21
22
export type ConnectorClaim = {
23
  claimId: string;
24
  secretHash: string;
25
  runtimeEndpoint: string;
26
  credentialPublicKey: string;
27
  profile: ConnectorAgentProfile;
28
  status: ConnectorClaimStatus;
29
  operatorId?: string;
30
  agentId?: string;
31
  credentialCiphertext?: string;
32
  createdAt: string;
33
  expiresAt: string;
34
  decidedAt?: string;
35
  connectedAt?: string;
36
};
37
38
export type PublicConnectorClaim = Omit<
39
  ConnectorClaim,
40
  'secretHash' | 'credentialPublicKey' | 'credentialCiphertext' | 'operatorId'
41
>;
42
43
export function createConnectorClaimInput(input: {
44
  runtimeEndpoint: string;
45
  credentialPublicKey: string;
46
  profile: ConnectorAgentProfile;
47
  lifetimeMinutes?: number;
48
}) {
49
  const key = createPublicKey(input.credentialPublicKey);
50
  if (key.asymmetricKeyType !== 'rsa') throw new Error('Connector key must be RSA');
51
  const details = key.asymmetricKeyDetails;
52
  if (details?.modulusLength && details.modulusLength < 2048)
53
    throw new Error('Connector RSA key must be at least 2048 bits');
54
  const claimSecret = `oc_cc_${randomBytes(32).toString('base64url')}`;
55
  const createdAt = new Date();
56
  const lifetimeMinutes = Math.max(5, Math.min(30, input.lifetimeMinutes ?? 15));
57
  const claim: ConnectorClaim = {
58
    claimId: randomUUID(),
59
    secretHash: hashConnectorClaimSecret(claimSecret),
60
    runtimeEndpoint: new URL(input.runtimeEndpoint).toString(),
61
    credentialPublicKey: input.credentialPublicKey,
62
    profile: ConnectorAgentProfileSchema.parse(input.profile),
63
    status: 'pending',
64
    createdAt: createdAt.toISOString(),
65
    expiresAt: new Date(createdAt.getTime() + lifetimeMinutes * 60_000).toISOString(),
66
  };
67
  return { claim, claimSecret };
68
}
69
70
export function hashConnectorClaimSecret(secret: string) {
71
  return createHash('sha256').update(secret).digest('base64url');
72
}
73
74
export function matchesConnectorClaimSecret(expectedHash: string, secret: string) {
75
  const expected = Buffer.from(expectedHash);
76
  const actual = Buffer.from(hashConnectorClaimSecret(secret));
77
  return expected.length === actual.length && timingSafeEqual(expected, actual);
78
}
79
80
export function connectorClaimExpired(claim: Pick<ConnectorClaim, 'expiresAt'>) {
81
  return Date.parse(claim.expiresAt) <= Date.now();
82
}
83
84
export function publicConnectorClaim(claim: ConnectorClaim): PublicConnectorClaim {
85
  const safe = { ...claim } as Partial<ConnectorClaim>;
86
  delete safe.secretHash;
87
  delete safe.credentialPublicKey;
88
  delete safe.credentialCiphertext;
89
  delete safe.operatorId;
90
  return safe as PublicConnectorClaim;
91
}
92
93
export function encryptConnectorCredential(publicKey: string, token: string) {
94
  return publicEncrypt(
95
    { key: createPublicKey(publicKey), oaepHash: 'sha256' },
96
    Buffer.from(token),
97
  ).toString('base64url');
98
}
99
100
export async function createConnectorAgent(
101
  store: OnboardingStore,
102
  operatorId: string,
103
  name: string,
104
  profile: ConnectorAgentProfile,
105
) {
106
  const agentName = name.trim();
107
  if (!agentName) throw new Error('Agent name is required');
108
  const now = new Date().toISOString();
109
  const state = await getOnboardingState(store, operatorId);
110
  let project = state.projects.find((item) => item.name === 'Connected agents');
111
  if (!project) {
112
    project = {
113
      projectId: `project_${randomUUID()}`,
114
      name: 'Connected agents',
115
      createdAt: now,
116
    } satisfies Project;
117
    await store.upsert(operatorId, 'project', project.projectId, project);
118
  }
119
  const agent: AgentProfile = {
120
    agentId: `agent_${randomUUID()}`,
121
    projectId: project.projectId,
122
    name: agentName,
123
    description: profile.description,
124
    framework: profile.framework,
125
    agentVersion: profile.agentVersion,
126
    ...(profile.modelProvider ? { modelProvider: profile.modelProvider } : {}),
127
    ...(profile.modelName ? { modelName: profile.modelName } : {}),
128
    nameProvenance: 'operator_attested',
129
    profileProvenance: 'self_declared',
130
    agentMode: 'persistent_runtime',
131
    transport: 'direct_a2a',
132
    autoPublish: false,
133
    autoAcceptPolicy: 'off',
134
    autoAcceptTaskCategories: [],
135
    capabilities: [...new Set(profile.capabilities)],
136
    limitations: [...new Set(profile.limitations)],
137
    identityMode: 'connector_claim',
138
    status: 'active',
139
    createdAt: now,
140
    updatedAt: now,
141
  };
142
  await store.upsert(operatorId, 'agent_profile', agent.agentId, agent, {
143
    provenance: 'operator_attested',
144
    schemaName: 'openclasp.agent_profile',
145
    schemaVersion: '0.1',
146
  });
147
  return { project, agent };
148
}