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:
14
src/app.ts
14
src/app.ts
@@ -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,
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
19
src/db/migrations/005_add_vault_path.sql
Normal file
19
src/db/migrations/005_add_vault_path.sql
Normal 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).';
|
||||
@@ -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'.
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
200
src/vault/VaultClient.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user