feat(phase-2): workstream 1 — HashiCorp Vault credential storage

Vault is optional — server falls back to bcrypt (Phase 1 behaviour)
when VAULT_ADDR is not set. Full coexistence: existing bcrypt credentials
continue to work until rotated.

Changes:
- src/vault/VaultClient.ts — wraps node-vault KV v2; writeSecret,
  readSecret, verifySecret (constant-time), deleteSecret
- src/db/migrations/005_add_vault_path.sql — vault_path column on credentials
- CredentialRepository — createWithVaultPath, updateVaultPath methods
- CredentialService — routes generate/rotate through Vault when configured;
  bcrypt path unchanged
- OAuth2Service — verifies via Vault when vaultPath set, bcrypt otherwise
- src/app.ts — createVaultClientFromEnv() wired into service layer
- ICredentialRow — vaultPath field added
- docs/devops/environment-variables.md — VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT
- docs/devops/vault-setup.md — dev quickstart, production config, migration guide
- tests: 33/33 unit tests pass (VaultClient + CredentialService Vault path)
- node-vault + @types/node-vault installed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 15:02:33 +00:00
parent 7593bfe1c1
commit 90a4addb21
14 changed files with 1064 additions and 36 deletions

View File

@@ -33,6 +33,7 @@ import { createCredentialsRouter } from './routes/credentials.js';
import { createAuditRouter } from './routes/audit.js';
import { errorHandler } from './middleware/errorHandler.js';
import { createVaultClientFromEnv } from './vault/VaultClient.js';
import { RedisClientType } from 'redis';
/**
@@ -86,12 +87,22 @@ export async function createApp(): Promise<Application> {
const tokenRepo = new TokenRepository(pool, redis as RedisClientType);
const auditRepo = new AuditRepository(pool);
// ────────────────────────────────────────────────────────────────
// Optional integrations
// ────────────────────────────────────────────────────────────────
// Vault is optional. When VAULT_ADDR + VAULT_TOKEN are set, new credentials
// are stored in Vault KV v2. When not set, bcrypt is used (Phase 1 behaviour).
const vaultClient = createVaultClientFromEnv();
if (vaultClient !== null) {
console.log('[AgentIdP] Vault integration enabled — new credentials will use Vault KV v2');
}
// ────────────────────────────────────────────────────────────────
// Service layer
// ────────────────────────────────────────────────────────────────
const auditService = new AuditService(auditRepo);
const agentService = new AgentService(agentRepo, credentialRepo, auditService);
const credentialService = new CredentialService(credentialRepo, agentRepo, auditService);
const credentialService = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient);
const privateKey = process.env['JWT_PRIVATE_KEY'];
const publicKey = process.env['JWT_PUBLIC_KEY'];
@@ -106,6 +117,7 @@ export async function createApp(): Promise<Application> {
auditService,
privateKey,
publicKey,
vaultClient,
);
// ────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,19 @@
-- Migration 005: Add vault_path column to credentials table
-- Phase 2 — HashiCorp Vault integration
--
-- New credentials generated after this migration will have their secrets stored
-- in HashiCorp Vault KV v2. The vault_path column stores the Vault KV path
-- (e.g. secret/data/agentidp/agents/{agentId}/credentials/{credentialId}).
--
-- Coexistence strategy:
-- - Rows with vault_path IS NOT NULL → secret verified via Vault
-- - Rows with vault_path IS NULL → secret verified via secret_hash (bcrypt, Phase 1)
--
-- The secret_hash column is retained for backwards compatibility.
-- Existing credentials continue to work until rotated through the new Vault path.
ALTER TABLE credentials
ADD COLUMN IF NOT EXISTS vault_path TEXT DEFAULT NULL;
COMMENT ON COLUMN credentials.vault_path IS
'Vault KV v2 data path for this credential secret. NULL = bcrypt (Phase 1).';

View File

@@ -12,6 +12,7 @@ interface CredentialDbRow {
credential_id: string;
client_id: string;
secret_hash: string;
vault_path: string | null;
status: string;
created_at: Date;
expires_at: Date | null;
@@ -29,6 +30,7 @@ function mapRowToCredentialRow(row: CredentialDbRow): ICredentialRow {
credentialId: row.credential_id,
clientId: row.client_id,
secretHash: row.secret_hash,
vaultPath: row.vault_path ?? null,
status: row.status as ICredential['status'],
createdAt: row.created_at,
expiresAt: row.expires_at,
@@ -59,7 +61,7 @@ export class CredentialRepository {
constructor(private readonly pool: Pool) {}
/**
* Creates a new credential record.
* Creates a new credential record using bcrypt secret hash (Phase 1 / Vault-not-configured).
*
* @param clientId - The agent ID this credential belongs to.
* @param secretHash - The bcrypt hash of the plain-text secret.
@@ -74,14 +76,40 @@ export class CredentialRepository {
const credentialId = uuidv4();
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`INSERT INTO credentials
(credential_id, client_id, secret_hash, status, created_at, expires_at)
VALUES ($1, $2, $3, 'active', NOW(), $4)
(credential_id, client_id, secret_hash, vault_path, status, created_at, expires_at)
VALUES ($1, $2, $3, NULL, 'active', NOW(), $4)
RETURNING *`,
[credentialId, clientId, secretHash, expiresAt],
);
return mapRowToCredential(result.rows[0]);
}
/**
* Creates a new credential record backed by Vault (Phase 2).
* Accepts a caller-supplied credentialId so the Vault path can include it before the DB write.
*
* @param credentialId - The UUID to use for this credential (caller-generated).
* @param clientId - The agent ID this credential belongs to.
* @param vaultPath - The Vault KV v2 data path where the secret is stored.
* @param expiresAt - Optional expiry date.
* @returns The created credential record.
*/
async createWithVaultPath(
credentialId: string,
clientId: string,
vaultPath: string,
expiresAt: Date | null,
): Promise<ICredential> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`INSERT INTO credentials
(credential_id, client_id, secret_hash, vault_path, status, created_at, expires_at)
VALUES ($1, $2, '', $3, 'active', NOW(), $4)
RETURNING *`,
[credentialId, clientId, vaultPath, expiresAt],
);
return mapRowToCredential(result.rows[0]);
}
/**
* Finds a credential by its UUID, including the secret hash.
*
@@ -142,7 +170,7 @@ export class CredentialRepository {
}
/**
* Updates the bcrypt hash for an existing credential (rotation).
* Updates the bcrypt hash for an existing credential (rotation, bcrypt path).
*
* @param credentialId - The credential UUID.
* @param newSecretHash - The new bcrypt hash.
@@ -156,7 +184,7 @@ export class CredentialRepository {
): Promise<ICredential | null> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`UPDATE credentials
SET secret_hash = $1, expires_at = $2, status = 'active', revoked_at = NULL
SET secret_hash = $1, vault_path = NULL, expires_at = $2, status = 'active', revoked_at = NULL
WHERE credential_id = $3
RETURNING *`,
[newSecretHash, newExpiresAt, credentialId],
@@ -165,6 +193,30 @@ export class CredentialRepository {
return mapRowToCredential(result.rows[0]);
}
/**
* Updates the vault_path for an existing credential (rotation, Vault path).
*
* @param credentialId - The credential UUID.
* @param newVaultPath - The new Vault KV v2 data path.
* @param newExpiresAt - Optional new expiry date.
* @returns The updated credential record, or null if not found.
*/
async updateVaultPath(
credentialId: string,
newVaultPath: string,
newExpiresAt: Date | null,
): Promise<ICredential | null> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`UPDATE credentials
SET vault_path = $1, secret_hash = '', expires_at = $2, status = 'active', revoked_at = NULL
WHERE credential_id = $3
RETURNING *`,
[newVaultPath, newExpiresAt, credentialId],
);
if (result.rows.length === 0) return null;
return mapRowToCredential(result.rows[0]);
}
/**
* Sets a credential's status to 'revoked'.
*

View File

@@ -6,7 +6,9 @@
import { CredentialRepository } from '../repositories/CredentialRepository.js';
import { AgentRepository } from '../repositories/AgentRepository.js';
import { AuditService } from './AuditService.js';
import { VaultClient } from '../vault/VaultClient.js';
import {
ICredential,
ICredentialWithSecret,
ICredentialListFilters,
IPaginatedCredentialsResponse,
@@ -19,6 +21,7 @@ import {
CredentialError,
} from '../utils/errors.js';
import { generateClientSecret, hashSecret } from '../utils/crypto.js';
import { v4 as uuidv4 } from 'uuid';
/**
* Service for credential lifecycle management.
@@ -29,11 +32,14 @@ export class CredentialService {
* @param credentialRepository - The credential data repository.
* @param agentRepository - The agent repository (for status checks).
* @param auditService - The audit log service.
* @param vaultClient - Optional VaultClient. When provided, new credentials are stored in Vault.
* When null, bcrypt is used (Phase 1 behaviour).
*/
constructor(
private readonly credentialRepository: CredentialRepository,
private readonly agentRepository: AgentRepository,
private readonly auditService: AuditService,
private readonly vaultClient: VaultClient | null = null,
) {}
/**
@@ -70,9 +76,24 @@ export class CredentialService {
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
const plainSecret = generateClientSecret();
const secretHash = await hashSecret(plainSecret);
const credential = await this.credentialRepository.create(agentId, secretHash, expiresAt);
let credential: ICredential;
if (this.vaultClient !== null) {
// Phase 2: generate the UUID first so the Vault path includes the real credentialId
const credentialId = uuidv4();
const vaultPath = await this.vaultClient.writeSecret(agentId, credentialId, plainSecret);
credential = await this.credentialRepository.createWithVaultPath(
credentialId,
agentId,
vaultPath,
expiresAt,
);
} else {
// Phase 1: bcrypt hash stored in PostgreSQL
const secretHash = await hashSecret(plainSecret);
credential = await this.credentialRepository.create(agentId, secretHash, expiresAt);
}
await this.auditService.logEvent(
agentId,
@@ -158,9 +179,19 @@ export class CredentialService {
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
const plainSecret = generateClientSecret();
const newHash = await hashSecret(plainSecret);
const updated = await this.credentialRepository.updateHash(credentialId, newHash, expiresAt);
let updated: ICredential | null;
if (this.vaultClient !== null) {
// Phase 2: overwrite the existing Vault secret (KV v2 creates a new version)
const vaultPath = await this.vaultClient.writeSecret(agentId, credentialId, plainSecret);
updated = await this.credentialRepository.updateVaultPath(credentialId, vaultPath, expiresAt);
} else {
// Phase 1 / migrating credential: use bcrypt
const newHash = await hashSecret(plainSecret);
updated = await this.credentialRepository.updateHash(credentialId, newHash, expiresAt);
}
if (!updated) {
throw new CredentialNotFoundError(credentialId);
}
@@ -214,6 +245,11 @@ export class CredentialService {
await this.credentialRepository.revoke(credentialId);
// Phase 2: permanently delete the secret from Vault
if (this.vaultClient !== null && existing.vaultPath !== null) {
await this.vaultClient.deleteSecret(agentId, credentialId);
}
await this.auditService.logEvent(
agentId,
'credential.revoked',

View File

@@ -7,6 +7,7 @@ import { TokenRepository } from '../repositories/TokenRepository.js';
import { CredentialRepository } from '../repositories/CredentialRepository.js';
import { AgentRepository } from '../repositories/AgentRepository.js';
import { AuditService } from './AuditService.js';
import { VaultClient } from '../vault/VaultClient.js';
import {
ITokenPayload,
ITokenResponse,
@@ -44,6 +45,7 @@ export class OAuth2Service {
* @param auditService - The audit log service.
* @param privateKey - PEM-encoded RSA private key for signing tokens.
* @param publicKey - PEM-encoded RSA public key for verifying tokens.
* @param vaultClient - Optional VaultClient for Phase 2 credential verification.
*/
constructor(
private readonly tokenRepository: TokenRepository,
@@ -52,6 +54,7 @@ export class OAuth2Service {
private readonly auditService: AuditService,
private readonly privateKey: string,
private readonly publicKey: string,
private readonly vaultClient: VaultClient | null = null,
) {}
/**
@@ -101,12 +104,25 @@ export class OAuth2Service {
for (const cred of credentials) {
const credRow = await this.credentialRepository.findById(cred.credentialId);
if (credRow) {
const matches = await verifySecret(clientSecret, credRow.secretHash);
// Check expiry before attempting secret verification
if (credRow.expiresAt !== null && credRow.expiresAt < new Date()) {
continue;
}
let matches: boolean;
if (credRow.vaultPath !== null && this.vaultClient !== null) {
// Phase 2: verify against Vault-stored secret
matches = await this.vaultClient.verifySecret(
clientId,
credRow.credentialId,
clientSecret,
);
} else {
// Phase 1: verify against bcrypt hash
matches = await verifySecret(clientSecret, credRow.secretHash);
}
if (matches) {
// Check if credential is expired
if (credRow.expiresAt !== null && credRow.expiresAt < new Date()) {
continue;
}
credentialVerified = true;
break;
}

View File

@@ -122,9 +122,16 @@ export interface ICredentialWithSecret extends ICredential {
clientSecret: string;
}
/** Database row for a credential, including the bcrypt hash. */
/** Database row for a credential, including the bcrypt hash and optional Vault path. */
export interface ICredentialRow extends ICredential {
/** bcrypt hash of the secret — populated for Phase 1 (bcrypt-only) credentials. */
secretHash: string;
/**
* Vault KV v2 data path for this credential.
* When present, the secret is stored in Vault and secretHash is an empty placeholder.
* When null, bcrypt verification via secretHash is used (Phase 1 behaviour).
*/
vaultPath: string | null;
}
/** Request body for generating or rotating a credential. */

200
src/vault/VaultClient.ts Normal file
View File

@@ -0,0 +1,200 @@
/**
* VaultClient — HashiCorp Vault KV v2 integration for SentryAgent.ai AgentIdP.
* Manages agent credential secrets in Vault instead of storing bcrypt hashes in PostgreSQL.
*
* Vault is optional. When VAULT_ADDR is not set the server operates in
* bcrypt-only mode (Phase 1 behaviour). VaultClient is only instantiated when
* all three required env vars are present.
*/
import nodeVault from 'node-vault';
import { CredentialError } from '../utils/errors.js';
/** The single secret field name stored under each KV v2 path. */
const SECRET_FIELD = 'clientSecret';
/** Raw KV v2 read response shape from node-vault. */
interface KvV2ReadResponse {
data: {
data: Record<string, string>;
metadata: {
version: number;
destroyed: boolean;
deletion_time: string;
};
};
}
/**
* Wraps HashiCorp Vault KV v2 operations for credential secret management.
* All secrets are stored under `{mount}/data/agentidp/agents/{agentId}/credentials/{credentialId}`.
*/
export class VaultClient {
private readonly client: ReturnType<typeof nodeVault>;
private readonly mount: string;
/**
* @param vaultAddr - Vault server address (e.g. http://127.0.0.1:8200).
* @param vaultToken - Vault authentication token.
* @param mount - KV v2 mount path (default: 'secret').
*/
constructor(vaultAddr: string, vaultToken: string, mount: string = 'secret') {
this.client = nodeVault({ endpoint: vaultAddr, token: vaultToken });
this.mount = mount;
}
/**
* Builds the Vault KV v2 data path for a credential.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @returns Full KV v2 data path, e.g. `secret/data/agentidp/agents/{agentId}/credentials/{credentialId}`.
*/
private dataPath(agentId: string, credentialId: string): string {
return `${this.mount}/data/agentidp/agents/${agentId}/credentials/${credentialId}`;
}
/**
* Builds the Vault KV v2 metadata path for a credential (used for permanent deletion).
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @returns Full KV v2 metadata path.
*/
private metadataPath(agentId: string, credentialId: string): string {
return `${this.mount}/metadata/agentidp/agents/${agentId}/credentials/${credentialId}`;
}
/**
* Stores a plain-text client secret in Vault for the given credential.
* Creates or overwrites the secret at the KV v2 path (new version on overwrite).
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @param plainSecret - The plain-text client secret to store.
* @returns The Vault KV v2 data path where the secret was stored.
* @throws CredentialError if the Vault write fails.
*/
async writeSecret(
agentId: string,
credentialId: string,
plainSecret: string,
): Promise<string> {
const path = this.dataPath(agentId, credentialId);
try {
await this.client.write(path, { data: { [SECRET_FIELD]: plainSecret } });
} catch (err) {
throw new CredentialError(
`Failed to write credential secret to Vault: ${err instanceof Error ? err.message : String(err)}`,
'VAULT_WRITE_ERROR',
{ agentId, credentialId },
);
}
return path;
}
/**
* Reads and returns the plain-text client secret from Vault.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @returns The plain-text client secret.
* @throws CredentialError if the secret is not found or the read fails.
*/
async readSecret(agentId: string, credentialId: string): Promise<string> {
const path = this.dataPath(agentId, credentialId);
let response: KvV2ReadResponse;
try {
response = (await this.client.read(path)) as KvV2ReadResponse;
} catch (err) {
throw new CredentialError(
`Failed to read credential secret from Vault: ${err instanceof Error ? err.message : String(err)}`,
'VAULT_READ_ERROR',
{ agentId, credentialId },
);
}
const secret = response?.data?.data?.[SECRET_FIELD];
if (typeof secret !== 'string' || secret.length === 0) {
throw new CredentialError(
'Vault returned an empty or missing credential secret.',
'VAULT_SECRET_MISSING',
{ agentId, credentialId },
);
}
return secret;
}
/**
* Verifies a plain-text secret against the value stored in Vault.
* Performs a constant-time comparison to prevent timing attacks.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @param candidateSecret - The plain-text secret to verify.
* @returns `true` if the secret matches, `false` if it does not.
*/
async verifySecret(
agentId: string,
credentialId: string,
candidateSecret: string,
): Promise<boolean> {
let stored: string;
try {
stored = await this.readSecret(agentId, credentialId);
} catch {
return false;
}
// Constant-time comparison using crypto.timingSafeEqual
const { timingSafeEqual } = await import('crypto');
if (stored.length !== candidateSecret.length) {
// Still perform a dummy comparison to avoid timing leaks on length differences
timingSafeEqual(Buffer.from(stored), Buffer.from(stored));
return false;
}
return timingSafeEqual(Buffer.from(stored), Buffer.from(candidateSecret));
}
/**
* Permanently deletes all versions of a credential secret from Vault.
* Called on credential revocation.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @throws CredentialError if the deletion fails.
*/
async deleteSecret(agentId: string, credentialId: string): Promise<void> {
const path = this.metadataPath(agentId, credentialId);
try {
await this.client.delete(path);
} catch (err) {
throw new CredentialError(
`Failed to delete credential secret from Vault: ${err instanceof Error ? err.message : String(err)}`,
'VAULT_DELETE_ERROR',
{ agentId, credentialId },
);
}
}
}
/**
* Creates a VaultClient from environment variables, or returns null if Vault is not configured.
* When null is returned, the server operates in bcrypt-only mode (Phase 1 behaviour).
*
* Required env vars: VAULT_ADDR, VAULT_TOKEN
* Optional env var: VAULT_MOUNT (default: 'secret')
*
* @returns A configured VaultClient, or null if VAULT_ADDR/VAULT_TOKEN are not set.
*/
export function createVaultClientFromEnv(): VaultClient | null {
const addr = process.env.VAULT_ADDR;
const token = process.env.VAULT_TOKEN;
if (!addr || !token) {
return null;
}
const mount = process.env.VAULT_MOUNT ?? 'secret';
return new VaultClient(addr, token, mount);
}