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:
14
src/app.ts
14
src/app.ts
@@ -23,12 +23,14 @@ import { AgentService } from './services/AgentService.js';
|
||||
import { CredentialService } from './services/CredentialService.js';
|
||||
import { OAuth2Service } from './services/OAuth2Service.js';
|
||||
import { OrgService } from './services/OrgService.js';
|
||||
import { DIDService } from './services/DIDService.js';
|
||||
|
||||
import { AgentController } from './controllers/AgentController.js';
|
||||
import { TokenController } from './controllers/TokenController.js';
|
||||
import { CredentialController } from './controllers/CredentialController.js';
|
||||
import { AuditController } from './controllers/AuditController.js';
|
||||
import { OrgController } from './controllers/OrgController.js';
|
||||
import { DIDController } from './controllers/DIDController.js';
|
||||
|
||||
import { createAgentsRouter } from './routes/agents.js';
|
||||
import { createTokenRouter } from './routes/token.js';
|
||||
@@ -37,11 +39,13 @@ import { createAuditRouter } from './routes/audit.js';
|
||||
import { createHealthRouter } from './routes/health.js';
|
||||
import { createMetricsRouter } from './routes/metrics.js';
|
||||
import { createOrgsRouter } from './routes/organizations.js';
|
||||
import { createDIDRouter } from './routes/did.js';
|
||||
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { createOpaMiddleware } from './middleware/opa.js';
|
||||
import { metricsMiddleware } from './middleware/metrics.js';
|
||||
import { createOrgContextMiddleware } from './middleware/orgContext.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { createVaultClientFromEnv } from './vault/VaultClient.js';
|
||||
import { RedisClientType } from 'redis';
|
||||
import path from 'path';
|
||||
@@ -117,7 +121,8 @@ export async function createApp(): Promise<Application> {
|
||||
// Service layer
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const auditService = new AuditService(auditRepo);
|
||||
const agentService = new AgentService(agentRepo, credentialRepo, auditService);
|
||||
const didService = new DIDService(pool, vaultClient, redis as RedisClientType);
|
||||
const agentService = new AgentService(agentRepo, credentialRepo, auditService, didService);
|
||||
const credentialService = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient);
|
||||
const orgService = new OrgService(orgRepo, agentRepo);
|
||||
|
||||
@@ -150,6 +155,7 @@ export async function createApp(): Promise<Application> {
|
||||
const credentialController = new CredentialController(credentialService);
|
||||
const auditController = new AuditController(auditService);
|
||||
const orgController = new OrgController(orgService);
|
||||
const didController = new DIDController(didService, agentRepo);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Org context middleware — sets PostgreSQL session variable app.organization_id
|
||||
@@ -169,7 +175,13 @@ export async function createApp(): Promise<Application> {
|
||||
// Prometheus metrics — unauthenticated, internal scraping only
|
||||
app.use('/metrics', createMetricsRouter());
|
||||
|
||||
// Well-known DID Document for the AgentIdP instance — unauthenticated
|
||||
app.get('/.well-known/did.json', (req, res, next) => {
|
||||
void didController.getInstanceDIDDocument(req, res, next);
|
||||
});
|
||||
|
||||
app.use(`${API_BASE}/agents`, createAgentsRouter(agentController, opaMiddleware));
|
||||
app.use(`${API_BASE}`, createDIDRouter(didController, authMiddleware, opaMiddleware));
|
||||
app.use(
|
||||
`${API_BASE}/agents/:agentId/credentials`,
|
||||
createCredentialsRouter(credentialController, opaMiddleware),
|
||||
|
||||
126
src/controllers/DIDController.ts
Normal file
126
src/controllers/DIDController.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* DID Controller for SentryAgent.ai AgentIdP.
|
||||
* HTTP handlers for W3C DID document and AGNTCY agent card endpoints.
|
||||
* No business logic — delegates entirely to DIDService.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { DIDService } from '../services/DIDService.js';
|
||||
import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
|
||||
const DECOMMISSIONED_CODE = 'AGENT_DECOMMISSIONED';
|
||||
const DECOMMISSIONED_MESSAGE =
|
||||
'Agent has been decommissioned — DID Document is no longer active';
|
||||
const DID_RESOLUTION_CONTENT_TYPE =
|
||||
'application/ld+json;profile="https://w3id.org/did-resolution"';
|
||||
|
||||
/**
|
||||
* Controller for W3C DID and AGNTCY agent card endpoints.
|
||||
* Receives DIDService and AgentRepository via constructor injection.
|
||||
*/
|
||||
export class DIDController {
|
||||
/**
|
||||
* @param didService - The DID management service.
|
||||
* @param _agentRepository - The agent repository (retained for future use, e.g. org-scoped pre-validation).
|
||||
*/
|
||||
constructor(
|
||||
private readonly didService: DIDService,
|
||||
_agentRepository: AgentRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handles GET /.well-known/did.json — returns the instance-level DID Document.
|
||||
* Unauthenticated. Returns a W3C DID Document for the AgentIdP instance.
|
||||
*
|
||||
* @param _req - Express request (unused).
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getInstanceDIDDocument = async (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const doc = await this.didService.buildInstanceDIDDocument();
|
||||
res.json(doc);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /agents/:agentId/did — returns the per-agent DID Document.
|
||||
* Unauthenticated. Returns 410 Gone if the agent is decommissioned.
|
||||
*
|
||||
* @param req - Express request with `agentId` path param.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getAgentDIDDocument = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { agentId } = req.params;
|
||||
const { document, deactivated } = await this.didService.buildAgentDIDDocument(agentId);
|
||||
|
||||
if (deactivated) {
|
||||
res.status(410).json({
|
||||
code: DECOMMISSIONED_CODE,
|
||||
message: DECOMMISSIONED_MESSAGE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(document);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /agents/:agentId/did/resolve — returns the W3C DID Resolution result.
|
||||
* Requires authentication and OPA authorization. Sets the correct LD+JSON content type.
|
||||
*
|
||||
* @param req - Express request with `agentId` path param.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
resolveAgentDID = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { agentId } = req.params;
|
||||
const result = await this.didService.buildResolutionResult(agentId);
|
||||
res.set('Content-Type', DID_RESOLUTION_CONTENT_TYPE).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /agents/:agentId/did/card — returns the AGNTCY agent card.
|
||||
* Unauthenticated. Provides a machine-readable identity summary for AGNTCY consumers.
|
||||
*
|
||||
* @param req - Express request with `agentId` path param.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getAgentCard = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { agentId } = req.params;
|
||||
const card = await this.didService.buildAgentCard(agentId);
|
||||
res.json(card);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
14
src/db/migrations/012_create_agent_did_keys_table.sql
Normal file
14
src/db/migrations/012_create_agent_did_keys_table.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE agent_did_keys (
|
||||
key_id VARCHAR(40) PRIMARY KEY,
|
||||
agent_id VARCHAR(40) NOT NULL UNIQUE REFERENCES agents(agent_id),
|
||||
organization_id VARCHAR(40) NOT NULL REFERENCES organizations(organization_id),
|
||||
public_key_jwk JSONB NOT NULL,
|
||||
vault_key_path VARCHAR(255) NOT NULL,
|
||||
key_type VARCHAR(20) NOT NULL DEFAULT 'EC',
|
||||
curve VARCHAR(10) NOT NULL DEFAULT 'P-256',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
rotated_at TIMESTAMPTZ,
|
||||
CONSTRAINT agent_did_keys_key_type_check CHECK (key_type IN ('EC', 'RSA'))
|
||||
);
|
||||
CREATE INDEX idx_agent_did_keys_agent_id ON agent_did_keys(agent_id);
|
||||
CREATE INDEX idx_agent_did_keys_org_id ON agent_did_keys(organization_id);
|
||||
3
src/db/migrations/013_add_did_columns_to_agents.sql
Normal file
3
src/db/migrations/013_add_did_columns_to_agents.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE agents
|
||||
ADD COLUMN did VARCHAR(255),
|
||||
ADD COLUMN did_created_at TIMESTAMPTZ;
|
||||
@@ -26,6 +26,10 @@ interface AgentRow {
|
||||
status: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
/** W3C DID identifier — populated after DID generation (Phase 3). */
|
||||
did: string | null;
|
||||
/** Timestamp when the DID was generated (Phase 3). */
|
||||
did_created_at: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +51,8 @@ function mapRowToAgent(row: AgentRow): IAgent {
|
||||
status: row.status as AgentStatus,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
did: row.did ?? undefined,
|
||||
didCreatedAt: row.did_created_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
55
src/routes/did.ts
Normal file
55
src/routes/did.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* DID routes for SentryAgent.ai AgentIdP.
|
||||
* Wires DIDController handlers to Express paths.
|
||||
*
|
||||
* Agent-level DID routes (mounted at /api/v1):
|
||||
* GET /agents/:agentId/did — no auth
|
||||
* GET /agents/:agentId/did/resolve — auth + OPA
|
||||
* GET /agents/:agentId/did/card — no auth
|
||||
*
|
||||
* The instance-level well-known route (GET /.well-known/did.json) is registered
|
||||
* directly on the Express app in app.ts, not through this router.
|
||||
*/
|
||||
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import { DIDController } from '../controllers/DIDController.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for agent-level DID endpoints.
|
||||
* Mount this router at `/api/v1` in app.ts.
|
||||
*
|
||||
* @param controller - The DID controller instance.
|
||||
* @param authMiddleware - The JWT authentication middleware.
|
||||
* @param opaMiddleware - The OPA authorization middleware.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createDIDRouter(
|
||||
controller: DIDController,
|
||||
authMiddleware: RequestHandler,
|
||||
opaMiddleware: RequestHandler,
|
||||
): Router {
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
// GET /agents/:agentId/did — unauthenticated
|
||||
router.get(
|
||||
'/agents/:agentId/did',
|
||||
asyncHandler(controller.getAgentDIDDocument.bind(controller)),
|
||||
);
|
||||
|
||||
// GET /agents/:agentId/did/resolve — requires auth + OPA
|
||||
router.get(
|
||||
'/agents/:agentId/did/resolve',
|
||||
authMiddleware,
|
||||
opaMiddleware,
|
||||
asyncHandler(controller.resolveAgentDID.bind(controller)),
|
||||
);
|
||||
|
||||
// GET /agents/:agentId/did/card — unauthenticated
|
||||
router.get(
|
||||
'/agents/:agentId/did/card',
|
||||
asyncHandler(controller.getAgentCard.bind(controller)),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
95
src/types/did.ts
Normal file
95
src/types/did.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* W3C DID Core 1.0 and AGNTCY extension types for SentryAgent.ai AgentIdP.
|
||||
* All interfaces are strictly typed — no `any` usage.
|
||||
*/
|
||||
|
||||
/** A W3C DID Core 1.0 verification method. */
|
||||
export interface IVerificationMethod {
|
||||
id: string;
|
||||
type: string;
|
||||
controller: string;
|
||||
publicKeyJwk: IPublicKeyJwk;
|
||||
}
|
||||
|
||||
/** JWK representation of a public key. */
|
||||
export interface IPublicKeyJwk {
|
||||
kty: string;
|
||||
crv?: string;
|
||||
x?: string;
|
||||
y?: string;
|
||||
/** RSA modulus (base64url). */
|
||||
n?: string;
|
||||
/** RSA public exponent (base64url). */
|
||||
e?: string;
|
||||
use?: string;
|
||||
kid?: string;
|
||||
}
|
||||
|
||||
/** A W3C DID Document service endpoint. */
|
||||
export interface IDIDService {
|
||||
id: string;
|
||||
type: string;
|
||||
serviceEndpoint: string;
|
||||
}
|
||||
|
||||
/** W3C DID Core 1.0 DID Document. */
|
||||
export interface IDIDDocument {
|
||||
'@context': string[];
|
||||
id: string;
|
||||
controller: string;
|
||||
verificationMethod: IVerificationMethod[];
|
||||
authentication: string[];
|
||||
assertionMethod?: string[];
|
||||
service?: IDIDService[];
|
||||
agntcy?: IAgntcyExtension;
|
||||
}
|
||||
|
||||
/** AGNTCY extension fields on a per-agent DID Document. */
|
||||
export interface IAgntcyExtension {
|
||||
agentId: string;
|
||||
agentType: string;
|
||||
capabilities: string[];
|
||||
deploymentEnv: string;
|
||||
owner: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/** W3C DID Resolution result format. */
|
||||
export interface IDIDResolutionResult {
|
||||
didDocument: IDIDDocument;
|
||||
didDocumentMetadata: {
|
||||
created: string;
|
||||
updated: string;
|
||||
deactivated: boolean;
|
||||
};
|
||||
didResolutionMetadata: {
|
||||
contentType: string;
|
||||
retrieved: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** AGNTCY-format agent card. */
|
||||
export interface IAgentCard {
|
||||
did: string;
|
||||
name: string;
|
||||
agentType: string;
|
||||
capabilities: string[];
|
||||
owner: string;
|
||||
version: string;
|
||||
deploymentEnv: string;
|
||||
identityProvider: string;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
/** Raw database row for agent_did_keys. */
|
||||
export interface IAgentDIDKeyRow {
|
||||
keyId: string;
|
||||
agentId: string;
|
||||
organizationId: string;
|
||||
publicKeyJwk: IPublicKeyJwk;
|
||||
vaultKeyPath: string;
|
||||
keyType: string;
|
||||
curve: string;
|
||||
createdAt: Date;
|
||||
rotatedAt: Date | null;
|
||||
}
|
||||
@@ -69,6 +69,10 @@ export interface IAgent {
|
||||
status: AgentStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
/** W3C DID identifier for this agent. Populated after DID generation. */
|
||||
did?: string;
|
||||
/** Timestamp when the DID was first generated for this agent. */
|
||||
didCreatedAt?: Date;
|
||||
}
|
||||
|
||||
/** Request body for registering a new AI agent. */
|
||||
|
||||
Reference in New Issue
Block a user