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:
SentryAgent.ai Developer
2026-03-30 00:47:59 +00:00
parent d252097f71
commit 3d1fff15f6
15 changed files with 2171 additions and 14 deletions

View File

@@ -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),

View 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);
}
};
}

View 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);

View File

@@ -0,0 +1,3 @@
ALTER TABLE agents
ADD COLUMN did VARCHAR(255),
ADD COLUMN did_created_at TIMESTAMPTZ;

View File

@@ -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
View 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;
}

View File

@@ -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
View 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
View 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;
}

View File

@@ -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. */