feat(phase-3): workstream 2 — W3C DIDs
Implements W3C DID Core 1.0 per-agent identity for every registered agent: Schema: - agent_did_keys table: stores EC P-256 public key JWK + Vault path for private key - agents.did + agents.did_created_at columns Key management: - EC P-256 key pair generated on every agent registration via Node.js crypto - Private key stored in Vault KV v2 (dev:no-vault marker when Vault not configured) - Public key JWK stored in PostgreSQL agent_did_keys table API (4 new endpoints): - GET /.well-known/did.json — instance DID Document (public, cached) - GET /api/v1/agents/:id/did — per-agent DID Document (public, 410 for decommissioned) - GET /api/v1/agents/:id/did/resolve — W3C DID Resolution result (agents:read scope) - GET /api/v1/agents/:id/did/card — AGNTCY agent card (public) Implementation: - DIDService: DID construction, key generation, Redis caching (TTL configurable) - DIDController: 410 Gone for decommissioned agents, correct Content-Type on resolve - AgentService: calls DIDService.generateDIDForAgent on every new registration Tests: 429 passing, DIDService 98.93% coverage, private key absence verified in all responses Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import { DIDService } from './DIDService.js';
|
||||
import {
|
||||
IAgent,
|
||||
ICreateAgentRequest,
|
||||
@@ -32,11 +33,15 @@ export class AgentService {
|
||||
* @param agentRepository - The agent data repository.
|
||||
* @param credentialRepository - The credential repository (for decommission cleanup).
|
||||
* @param auditService - The audit log service.
|
||||
* @param didService - Optional DIDService. When provided, a W3C DID is generated for each
|
||||
* newly registered agent. When null/undefined, DID generation is skipped
|
||||
* (backward-compatible default).
|
||||
*/
|
||||
constructor(
|
||||
private readonly agentRepository: AgentRepository,
|
||||
private readonly credentialRepository: CredentialRepository,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly didService: DIDService | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -72,6 +77,12 @@ export class AgentService {
|
||||
|
||||
const agent = await this.agentRepository.create(data);
|
||||
|
||||
// Generate a W3C DID for the new agent when DIDService is available
|
||||
if (this.didService !== null) {
|
||||
const organizationId = data.organizationId ?? 'org_system';
|
||||
await this.didService.generateDIDForAgent(agent.agentId, organizationId);
|
||||
}
|
||||
|
||||
// Synchronous audit insert
|
||||
await this.auditService.logEvent(
|
||||
agent.agentId,
|
||||
|
||||
490
src/services/DIDService.ts
Normal file
490
src/services/DIDService.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* DID Service for SentryAgent.ai AgentIdP.
|
||||
* Manages W3C DID Core 1.0 document generation, key management, and AGNTCY agent cards.
|
||||
*
|
||||
* Key management strategy:
|
||||
* - When VAULT_ADDR + VAULT_TOKEN are set: private keys are stored in Vault KV v2 at
|
||||
* `{mount}/data/agentidp/agents/{agentId}/did-key`.
|
||||
* - When Vault is not configured (dev mode): `vault_key_path` column stores the marker
|
||||
* `dev:no-vault` and the private key is NOT persisted (ephemeral dev keys only).
|
||||
*/
|
||||
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
import { generateKeyPairSync } from 'crypto';
|
||||
import nodeVault from 'node-vault';
|
||||
import { RedisClientType } from 'redis';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
import { VaultClient } from '../vault/VaultClient.js';
|
||||
import { AgentNotFoundError } from '../utils/errors.js';
|
||||
import {
|
||||
IDIDDocument,
|
||||
IDIDResolutionResult,
|
||||
IAgentCard,
|
||||
IPublicKeyJwk,
|
||||
IVerificationMethod,
|
||||
IDIDService,
|
||||
IAgntcyExtension,
|
||||
} from '../types/did.js';
|
||||
import { IAgent } from '../types/index.js';
|
||||
|
||||
/** Raw row from the agents ⨯ agent_did_keys join query. */
|
||||
interface AgentWithDIDKeyRow {
|
||||
agent_id: string;
|
||||
organization_id: string;
|
||||
email: string;
|
||||
agent_type: string;
|
||||
version: string;
|
||||
capabilities: string[];
|
||||
owner: string;
|
||||
deployment_env: string;
|
||||
status: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
did: string | null;
|
||||
did_created_at: Date | null;
|
||||
key_id: string | null;
|
||||
public_key_jwk: IPublicKeyJwk | null;
|
||||
vault_key_path: string | null;
|
||||
key_type: string | null;
|
||||
curve: string | null;
|
||||
key_created_at: Date | null;
|
||||
}
|
||||
|
||||
/** Result of the internal agent+key lookup. */
|
||||
interface AgentWithDIDKey {
|
||||
agent: IAgent;
|
||||
keyId: string | null;
|
||||
publicKeyJwk: IPublicKeyJwk | null;
|
||||
vaultKeyPath: string | null;
|
||||
keyType: string | null;
|
||||
curve: string | null;
|
||||
keyCreatedAt: Date | null;
|
||||
}
|
||||
|
||||
/** DID Document build result including deactivation metadata. */
|
||||
export interface IAgentDIDDocumentResult {
|
||||
document: IDIDDocument;
|
||||
deactivated: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const DID_CONTEXT_BASE = 'https://www.w3.org/ns/did/v1';
|
||||
const DID_CONTEXT_AGNTCY = 'https://w3id.org/agntcy/v1';
|
||||
const IDENTITY_PROVIDER_URL = 'https://idp.sentryagent.ai';
|
||||
|
||||
/**
|
||||
* Service for W3C DID Core 1.0 document management, key generation, and AGNTCY agent cards.
|
||||
* Integrates with Vault for private key storage and Redis for DID document caching.
|
||||
*/
|
||||
export class DIDService {
|
||||
/**
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param _vaultClient - Optional VaultClient; retained for API consistency and future use.
|
||||
* DID private keys are stored via node-vault directly using env vars.
|
||||
* @param redis - Redis client for DID document caching.
|
||||
*/
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
// VaultClient is accepted for API consistency and future use (e.g. token-based Vault auth).
|
||||
// DID private keys are stored via node-vault directly using env vars — see storePrivateKey().
|
||||
_vaultClient: VaultClient | null,
|
||||
private readonly redis: RedisClientType,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates an EC P-256 key pair for a new agent.
|
||||
* Stores the private key in Vault (or marks as dev:no-vault in dev mode).
|
||||
* Stores the public key JWK in agent_did_keys table.
|
||||
* Populates agent.did and agent.did_created_at in the agents table.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @param organizationId - The organization UUID.
|
||||
* @returns The generated DID and the public key JWK.
|
||||
*/
|
||||
async generateDIDForAgent(
|
||||
agentId: string,
|
||||
organizationId: string,
|
||||
): Promise<{ did: string; publicKeyJwk: IPublicKeyJwk }> {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ec', {
|
||||
namedCurve: 'P-256',
|
||||
});
|
||||
|
||||
const publicKeyJwk = publicKey.export({ format: 'jwk' }) as IPublicKeyJwk;
|
||||
const privateKeyPem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string;
|
||||
|
||||
const did = this.buildAgentDID(agentId);
|
||||
|
||||
// Store private key — Vault if configured, dev marker otherwise
|
||||
const vaultKeyPath = await this.storePrivateKey(agentId, privateKeyPem);
|
||||
|
||||
const keyId = 'key_' + ulid();
|
||||
|
||||
// Insert into agent_did_keys
|
||||
await this.pool.query(
|
||||
`INSERT INTO agent_did_keys
|
||||
(key_id, agent_id, organization_id, public_key_jwk, vault_key_path, key_type, curve, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'EC', 'P-256', NOW())`,
|
||||
[keyId, agentId, organizationId, JSON.stringify(publicKeyJwk), vaultKeyPath],
|
||||
);
|
||||
|
||||
// Update agents with the DID
|
||||
await this.pool.query(
|
||||
`UPDATE agents SET did = $1, did_created_at = NOW() WHERE agent_id = $2`,
|
||||
[did, agentId],
|
||||
);
|
||||
|
||||
return { did, publicKeyJwk };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns the root instance DID Document for AgentIdP itself.
|
||||
* Uses the DID_WEB_DOMAIN environment variable to construct the DID.
|
||||
* Result is cached in Redis under `did:doc:instance`.
|
||||
*
|
||||
* @returns The instance-level W3C DID Document.
|
||||
* @throws Error if DID_WEB_DOMAIN is not configured.
|
||||
*/
|
||||
async buildInstanceDIDDocument(): Promise<IDIDDocument> {
|
||||
const cacheKey = 'did:doc:instance';
|
||||
const cached = await this.getCachedDoc(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const instanceDid = this.buildInstanceDID();
|
||||
const domain = this.getDIDWebDomain();
|
||||
|
||||
const vmId = `${instanceDid}#keys-1`;
|
||||
const verificationMethod: IVerificationMethod = {
|
||||
id: vmId,
|
||||
type: 'JsonWebKey2020',
|
||||
controller: instanceDid,
|
||||
publicKeyJwk: {
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
use: 'sig',
|
||||
},
|
||||
};
|
||||
|
||||
const serviceEndpoint: IDIDService = {
|
||||
id: `${instanceDid}#agent-registry`,
|
||||
type: 'AgentIdentityProvider',
|
||||
serviceEndpoint: `https://${domain}/api/v1`,
|
||||
};
|
||||
|
||||
const doc: IDIDDocument = {
|
||||
'@context': [DID_CONTEXT_BASE],
|
||||
id: instanceDid,
|
||||
controller: instanceDid,
|
||||
verificationMethod: [verificationMethod],
|
||||
authentication: [vmId],
|
||||
assertionMethod: [vmId],
|
||||
service: [serviceEndpoint],
|
||||
};
|
||||
|
||||
await this.cacheDoc(cacheKey, doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns a per-agent DID Document.
|
||||
* Returns a deactivated document structure if the agent is decommissioned.
|
||||
* Result is cached in Redis under `did:doc:{agentId}`.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @returns DID Document, deactivation flag, and timestamps.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
*/
|
||||
async buildAgentDIDDocument(agentId: string): Promise<IAgentDIDDocumentResult> {
|
||||
const cacheKey = `did:doc:${agentId}`;
|
||||
|
||||
const agentWithKey = await this.getAgentWithDIDKey(agentId);
|
||||
if (!agentWithKey) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
const { agent, publicKeyJwk, keyCreatedAt } = agentWithKey;
|
||||
const deactivated = agent.status === 'decommissioned';
|
||||
|
||||
// For decommissioned agents, do not serve stale cached documents
|
||||
if (!deactivated) {
|
||||
const cached = await this.getCachedDoc(cacheKey);
|
||||
if (cached !== null) {
|
||||
return {
|
||||
document: cached,
|
||||
deactivated: false,
|
||||
createdAt: keyCreatedAt ?? agent.createdAt,
|
||||
updatedAt: agent.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const agentDid = agent.did ?? this.buildAgentDID(agentId);
|
||||
const instanceDid = this.buildInstanceDID();
|
||||
const vmId = `${agentDid}#keys-1`;
|
||||
|
||||
const verificationMethods: IVerificationMethod[] = [];
|
||||
|
||||
if (publicKeyJwk !== null) {
|
||||
verificationMethods.push({
|
||||
id: vmId,
|
||||
type: 'JsonWebKey2020',
|
||||
controller: agentDid,
|
||||
publicKeyJwk,
|
||||
});
|
||||
}
|
||||
|
||||
const services: IDIDService[] = [];
|
||||
|
||||
if (deactivated) {
|
||||
services.push({
|
||||
id: `${agentDid}#status`,
|
||||
type: 'AgentStatus',
|
||||
serviceEndpoint: 'decommissioned',
|
||||
});
|
||||
}
|
||||
|
||||
const agntcyExtension: IAgntcyExtension = {
|
||||
agentId: agent.agentId,
|
||||
agentType: agent.agentType,
|
||||
capabilities: agent.capabilities,
|
||||
deploymentEnv: agent.deploymentEnv,
|
||||
owner: agent.owner,
|
||||
version: agent.version,
|
||||
};
|
||||
|
||||
const doc: IDIDDocument = {
|
||||
'@context': [DID_CONTEXT_BASE, DID_CONTEXT_AGNTCY],
|
||||
id: agentDid,
|
||||
controller: instanceDid,
|
||||
verificationMethod: verificationMethods,
|
||||
authentication: publicKeyJwk !== null ? [vmId] : [],
|
||||
assertionMethod: publicKeyJwk !== null ? [vmId] : [],
|
||||
service: services.length > 0 ? services : undefined,
|
||||
agntcy: agntcyExtension,
|
||||
};
|
||||
|
||||
if (!deactivated) {
|
||||
await this.cacheDoc(cacheKey, doc);
|
||||
}
|
||||
|
||||
return {
|
||||
document: doc,
|
||||
deactivated,
|
||||
createdAt: keyCreatedAt ?? agent.createdAt,
|
||||
updatedAt: agent.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the W3C DID Resolution result for a given agent.
|
||||
* Wraps the DID Document with resolution and document metadata.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @returns A complete W3C DID Resolution result.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
*/
|
||||
async buildResolutionResult(agentId: string): Promise<IDIDResolutionResult> {
|
||||
const { document, deactivated, createdAt, updatedAt } =
|
||||
await this.buildAgentDIDDocument(agentId);
|
||||
|
||||
return {
|
||||
didDocument: document,
|
||||
didDocumentMetadata: {
|
||||
created: createdAt.toISOString(),
|
||||
updated: updatedAt.toISOString(),
|
||||
deactivated,
|
||||
},
|
||||
didResolutionMetadata: {
|
||||
contentType: 'application/did+ld+json',
|
||||
retrieved: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an AGNTCY-format agent card from the agent's DID Document.
|
||||
* The agent card provides a machine-readable identity summary for AGNTCY consumers.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @returns An AGNTCY agent card.
|
||||
* @throws AgentNotFoundError if the agent does not exist.
|
||||
*/
|
||||
async buildAgentCard(agentId: string): Promise<IAgentCard> {
|
||||
const agentWithKey = await this.getAgentWithDIDKey(agentId);
|
||||
if (!agentWithKey) {
|
||||
throw new AgentNotFoundError(agentId);
|
||||
}
|
||||
|
||||
const { agent, keyCreatedAt } = agentWithKey;
|
||||
const agentDid = agent.did ?? this.buildAgentDID(agentId);
|
||||
|
||||
return {
|
||||
did: agentDid,
|
||||
name: agent.email,
|
||||
agentType: agent.agentType,
|
||||
capabilities: agent.capabilities,
|
||||
owner: agent.owner,
|
||||
version: agent.version,
|
||||
deploymentEnv: agent.deploymentEnv,
|
||||
identityProvider: IDENTITY_PROVIDER_URL,
|
||||
issuedAt: (keyCreatedAt ?? agent.createdAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reads DID_WEB_DOMAIN from the environment.
|
||||
*
|
||||
* @returns The configured DID Web domain (e.g. `idp.sentryagent.ai`).
|
||||
* @throws Error if DID_WEB_DOMAIN is not set.
|
||||
*/
|
||||
private getDIDWebDomain(): string {
|
||||
const domain = process.env['DID_WEB_DOMAIN'];
|
||||
if (!domain) {
|
||||
throw new Error('DID_WEB_DOMAIN environment variable is required');
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the per-agent DID using the did:web method.
|
||||
* Format: `did:web:{domain}:agents:{agentId}`
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @returns The did:web DID string for the agent.
|
||||
*/
|
||||
private buildAgentDID(agentId: string): string {
|
||||
const domain = this.getDIDWebDomain();
|
||||
return `did:web:${domain}:agents:${agentId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the instance-level DID for AgentIdP itself.
|
||||
* Format: `did:web:{domain}`
|
||||
*
|
||||
* @returns The did:web DID string for the AgentIdP instance.
|
||||
*/
|
||||
private buildInstanceDID(): string {
|
||||
const domain = this.getDIDWebDomain();
|
||||
return `did:web:${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a cached DID Document from Redis.
|
||||
*
|
||||
* @param key - Redis cache key.
|
||||
* @returns The cached DID Document, or null if not found.
|
||||
*/
|
||||
private async getCachedDoc(key: string): Promise<IDIDDocument | null> {
|
||||
const raw = await this.redis.get(key);
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as IDIDDocument;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a DID Document in Redis with the configured TTL.
|
||||
* TTL is read from DID_DOCUMENT_CACHE_TTL_SECONDS env (default: 300).
|
||||
*
|
||||
* @param key - Redis cache key.
|
||||
* @param doc - The DID Document to cache.
|
||||
*/
|
||||
private async cacheDoc(key: string, doc: IDIDDocument): Promise<void> {
|
||||
const ttl = parseInt(process.env['DID_DOCUMENT_CACHE_TTL_SECONDS'] ?? '300', 10);
|
||||
await this.redis.set(key, JSON.stringify(doc), { EX: ttl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an agent record joined with its DID key from the database.
|
||||
*
|
||||
* @param agentId - The agent UUID.
|
||||
* @returns The agent with its associated DID key row, or null if not found.
|
||||
*/
|
||||
private async getAgentWithDIDKey(agentId: string): Promise<AgentWithDIDKey | null> {
|
||||
const result: QueryResult<AgentWithDIDKeyRow> = await this.pool.query(
|
||||
`SELECT
|
||||
a.agent_id, a.organization_id, a.email, a.agent_type, a.version,
|
||||
a.capabilities, a.owner, a.deployment_env, a.status,
|
||||
a.created_at, a.updated_at, a.did, a.did_created_at,
|
||||
k.key_id, k.public_key_jwk, k.vault_key_path,
|
||||
k.key_type, k.curve, k.created_at AS key_created_at
|
||||
FROM agents a
|
||||
LEFT JOIN agent_did_keys k ON k.agent_id = a.agent_id
|
||||
WHERE a.agent_id = $1`,
|
||||
[agentId],
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
const agent: IAgent = {
|
||||
agentId: row.agent_id,
|
||||
organizationId: row.organization_id,
|
||||
email: row.email,
|
||||
agentType: row.agent_type as IAgent['agentType'],
|
||||
version: row.version,
|
||||
capabilities: row.capabilities,
|
||||
owner: row.owner,
|
||||
deploymentEnv: row.deployment_env as IAgent['deploymentEnv'],
|
||||
status: row.status as IAgent['status'],
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
did: row.did ?? undefined,
|
||||
didCreatedAt: row.did_created_at ?? undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
agent,
|
||||
keyId: row.key_id,
|
||||
publicKeyJwk: row.public_key_jwk,
|
||||
vaultKeyPath: row.vault_key_path,
|
||||
keyType: row.key_type,
|
||||
curve: row.curve,
|
||||
keyCreatedAt: row.key_created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a DID private key PEM in Vault (if configured) or records a dev marker.
|
||||
*
|
||||
* When VAULT_ADDR and VAULT_TOKEN are set, the private key is written to Vault KV v2
|
||||
* at `{mount}/data/agentidp/agents/{agentId}/did-key` and the Vault path is returned.
|
||||
*
|
||||
* When Vault is not configured (dev/test mode), the private key is NOT persisted and
|
||||
* the string `dev:no-vault` is returned as the path marker. This is safe for local
|
||||
* development only — keys cannot be recovered in this mode.
|
||||
*
|
||||
* @param agentId - The agent UUID (used to construct the Vault path).
|
||||
* @param privateKeyPem - The PKCS#8 PEM-encoded private key to store.
|
||||
* @returns The Vault path where the key was stored, or `dev:no-vault`.
|
||||
*/
|
||||
private async storePrivateKey(agentId: string, privateKeyPem: string): Promise<string> {
|
||||
const vaultAddr = process.env['VAULT_ADDR'];
|
||||
const vaultToken = process.env['VAULT_TOKEN'];
|
||||
|
||||
if (vaultAddr && vaultToken) {
|
||||
const mount = process.env['VAULT_MOUNT'] ?? 'secret';
|
||||
const vaultPath = `${mount}/data/agentidp/agents/${agentId}/did-key`;
|
||||
const vault = nodeVault({ endpoint: vaultAddr, token: vaultToken });
|
||||
await vault.write(vaultPath, { data: { privateKeyPem } });
|
||||
return vaultPath;
|
||||
}
|
||||
|
||||
// Dev mode: private key is not persisted — only a marker is stored
|
||||
return 'dev:no-vault';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user