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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user