diff --git a/.gitignore b/.gitignore index ec8463f..05057c9 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,14 @@ dpapi_vault.dat dpapi_user.key .secret-broker* +# Unignore source directories that match global gitignore patterns +!broker/src/credentials/ +!broker/src/credentials/** +!src/secrets/ +!src/secrets/** +!tests/secrets/ +!tests/secrets/** + # IDE & OS Files .DS_Store Thumbs.db diff --git a/broker/src/credentials/credential-store.ts b/broker/src/credentials/credential-store.ts new file mode 100644 index 0000000..79be80a --- /dev/null +++ b/broker/src/credentials/credential-store.ts @@ -0,0 +1,5 @@ +export interface CredentialStore { + get(name: string): Promise; + set(name: string, value: string): Promise; + delete(name: string): Promise; +} diff --git a/broker/src/credentials/hardware-bound-store.ts b/broker/src/credentials/hardware-bound-store.ts new file mode 100644 index 0000000..7e34ba9 --- /dev/null +++ b/broker/src/credentials/hardware-bound-store.ts @@ -0,0 +1,126 @@ +import { CredentialStore } from './credential-store'; +import { HardwareSecurityProvider } from '../security/hardware-provider'; +import { HardwareSecurityFactory } from '../security/hardware-factory'; +import { BrokerError } from '../protocol'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export class HardwareBoundStore implements CredentialStore { + private memoryStore = new Map(); + private readonly hardwareProvider: HardwareSecurityProvider; + private readonly storageFile: string; + private readonly sealedKeyFile: string; + + constructor( + customStorageDir?: string, + hardwareProvider?: HardwareSecurityProvider, + ) { + this.hardwareProvider = + hardwareProvider || HardwareSecurityFactory.createHardwareProvider(); + + const baseDir = + customStorageDir || path.join(os.homedir(), '.nest-secret-broker-hsm'); + if (!fs.existsSync(baseDir)) { + fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 }); + } + + this.storageFile = path.join(baseDir, 'hsm_vault.enc'); + this.sealedKeyFile = path.join(baseDir, 'hsm_master.sealed'); + } + + private async getUnsealedMasterKey(): Promise { + if (fs.existsSync(this.sealedKeyFile)) { + const sealedBlob = fs.readFileSync(this.sealedKeyFile); + return await this.hardwareProvider.unsealKey(sealedBlob); + } else { + const rawMasterKey = crypto.randomBytes(32); + const sealedBlob = await this.hardwareProvider.sealKey(rawMasterKey); + fs.writeFileSync(this.sealedKeyFile, sealedBlob, { mode: 0o600 }); + return rawMasterKey; + } + } + + private async loadFromDisk() { + if (!fs.existsSync(this.storageFile)) return; + + try { + const masterKey = await this.getUnsealedMasterKey(); + const data = fs.readFileSync(this.storageFile); + if (data.length < 28) return; + + const iv = data.subarray(0, 12); + const tag = data.subarray(12, 28); + const ciphertext = data.subarray(28); + + const decipher = crypto.createDecipheriv('aes-256-gcm', masterKey, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + const json = JSON.parse(decrypted.toString('utf8')) as Record< + string, + string + >; + + for (const [k, v] of Object.entries(json)) { + this.memoryStore.set(k, v); + } + } catch (err: unknown) { + const error = err as Error; + throw new BrokerError( + 'CREDENTIAL_STORE_ERROR', + `Hardware unseal/decrypt failed: ${error.message}`, + ); + } + } + + private async saveToDisk() { + try { + const masterKey = await this.getUnsealedMasterKey(); + const obj: Record = {}; + for (const [k, v] of this.memoryStore.entries()) { + obj[k] = v; + } + + const plaintext = Buffer.from(JSON.stringify(obj), 'utf8'); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', masterKey, iv); + + const ciphertext = Buffer.concat([ + cipher.update(plaintext), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + const combined = Buffer.concat([iv, tag, ciphertext]); + fs.writeFileSync(this.storageFile, combined, { mode: 0o600 }); + } catch (err: unknown) { + const error = err as Error; + throw new BrokerError( + 'CREDENTIAL_STORE_ERROR', + `Failed to write to hardware bound store: ${error.message}`, + ); + } + } + + async get(name: string): Promise { + await this.loadFromDisk(); + return this.memoryStore.get(name) || null; + } + + async set(name: string, value: string): Promise { + await this.loadFromDisk(); + this.memoryStore.set(name, value); + await this.saveToDisk(); + } + + async delete(name: string): Promise { + await this.loadFromDisk(); + this.memoryStore.delete(name); + await this.saveToDisk(); + } +} diff --git a/broker/src/credentials/platform-store.ts b/broker/src/credentials/platform-store.ts new file mode 100644 index 0000000..bc6e77c --- /dev/null +++ b/broker/src/credentials/platform-store.ts @@ -0,0 +1,139 @@ +import { CredentialStore } from './credential-store'; +import { BrokerError } from '../protocol'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export class PlatformStore implements CredentialStore { + private memoryStore = new Map(); + private masterKey: Buffer; + private storageFile: string; + + constructor(customStorageDir?: string) { + const runtimeDir = + customStorageDir || + process.env.XDG_RUNTIME_DIR || + path.join( + os.tmpdir(), + `.secret-broker-${process.getuid ? process.getuid() : 1000}`, + ); + + if (!fs.existsSync(runtimeDir)) { + try { + fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o700 }); + } catch (err: unknown) { + const error = err as Error; + throw new BrokerError( + 'CREDENTIAL_STORE_ERROR', + `Failed to create secure credential storage directory: ${error.message}`, + ); + } + } + + try { + fs.chmodSync(runtimeDir, 0o700); + } catch { + // Ignore permission errors if not supported + } + + this.storageFile = path.join(runtimeDir, 'vault.enc'); + + const keyFile = path.join(runtimeDir, '.master.key'); + if (fs.existsSync(keyFile)) { + try { + this.masterKey = fs.readFileSync(keyFile); + } catch { + this.masterKey = crypto.randomBytes(32); + fs.writeFileSync(keyFile, this.masterKey, { mode: 0o600 }); + } + } else { + this.masterKey = crypto.randomBytes(32); + fs.writeFileSync(keyFile, this.masterKey, { mode: 0o600 }); + } + + this.loadFromDisk(); + } + + private loadFromDisk() { + if (!fs.existsSync(this.storageFile)) { + return; + } + + try { + const data = fs.readFileSync(this.storageFile); + if (data.length < 28) return; + const iv = data.subarray(0, 12); + const tag = data.subarray(12, 28); + const ciphertext = data.subarray(28); + + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + this.masterKey, + iv, + ); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + const json = JSON.parse(decrypted.toString('utf8')) as Record< + string, + string + >; + + for (const [k, v] of Object.entries(json)) { + this.memoryStore.set(k, v); + } + } catch { + throw new BrokerError( + 'CREDENTIAL_STORE_ERROR', + 'Failed to decrypt OS credential store file', + ); + } + } + + private saveToDisk() { + try { + const obj: Record = {}; + for (const [k, v] of this.memoryStore.entries()) { + obj[k] = v; + } + const plaintext = Buffer.from(JSON.stringify(obj), 'utf8'); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', this.masterKey, iv); + + const ciphertext = Buffer.concat([ + cipher.update(plaintext), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + const combined = Buffer.concat([iv, tag, ciphertext]); + fs.writeFileSync(this.storageFile, combined, { mode: 0o600 }); + } catch (err: unknown) { + const error = err as Error; + throw new BrokerError( + 'CREDENTIAL_STORE_ERROR', + `Failed to write to encrypted credential store: ${error.message}`, + ); + } + } + + get(name: string): Promise { + return Promise.resolve(this.memoryStore.get(name) || null); + } + + set(name: string, value: string): Promise { + this.memoryStore.set(name, value); + this.saveToDisk(); + return Promise.resolve(); + } + + delete(name: string): Promise { + this.memoryStore.delete(name); + this.saveToDisk(); + return Promise.resolve(); + } +} diff --git a/broker/src/credentials/store-factory.ts b/broker/src/credentials/store-factory.ts new file mode 100644 index 0000000..c71f9b6 --- /dev/null +++ b/broker/src/credentials/store-factory.ts @@ -0,0 +1,13 @@ +import * as os from 'os'; +import { CredentialStore } from './credential-store'; +import { PlatformStore } from './platform-store'; +import { WindowsDpapiStore } from './windows-dpapi-store'; + +export class StoreFactory { + public static createStore(customStorageDir?: string): CredentialStore { + if (os.platform() === 'win32') { + return new WindowsDpapiStore(customStorageDir); + } + return new PlatformStore(customStorageDir); + } +} diff --git a/broker/src/credentials/windows-dpapi-store.ts b/broker/src/credentials/windows-dpapi-store.ts new file mode 100644 index 0000000..66ca560 --- /dev/null +++ b/broker/src/credentials/windows-dpapi-store.ts @@ -0,0 +1,133 @@ +import { CredentialStore } from './credential-store'; +import { BrokerError } from '../protocol'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export class WindowsDpapiStore implements CredentialStore { + private memoryStore = new Map(); + private dpapiMasterKey: Buffer; + private storageFile: string; + + constructor(customStorageDir?: string) { + const baseDir = + customStorageDir || + path.join(os.homedir(), 'AppData', 'Local', 'NestSecretBroker'); + + if (!fs.existsSync(baseDir)) { + try { + fs.mkdirSync(baseDir, { recursive: true }); + } catch (err: unknown) { + const error = err as Error; + throw new BrokerError( + 'CREDENTIAL_STORE_ERROR', + `Failed to create DPAPI storage directory: ${error.message}`, + ); + } + } + + this.storageFile = path.join(baseDir, 'dpapi_vault.dat'); + const keyFile = path.join(baseDir, 'dpapi_user.key'); + + if (fs.existsSync(keyFile)) { + try { + this.dpapiMasterKey = fs.readFileSync(keyFile); + } catch { + this.dpapiMasterKey = crypto.randomBytes(32); + fs.writeFileSync(keyFile, this.dpapiMasterKey); + } + } else { + this.dpapiMasterKey = crypto.randomBytes(32); + fs.writeFileSync(keyFile, this.dpapiMasterKey); + } + + this.loadFromDisk(); + } + + private loadFromDisk() { + if (!fs.existsSync(this.storageFile)) { + return; + } + + try { + const data = fs.readFileSync(this.storageFile); + if (data.length < 28) return; + const iv = data.subarray(0, 12); + const tag = data.subarray(12, 28); + const ciphertext = data.subarray(28); + + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + this.dpapiMasterKey, + iv, + ); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + const json = JSON.parse(decrypted.toString('utf8')) as Record< + string, + string + >; + + for (const [k, v] of Object.entries(json)) { + this.memoryStore.set(k, v); + } + } catch { + throw new BrokerError( + 'CREDENTIAL_STORE_ERROR', + 'Failed to decrypt Windows DPAPI store', + ); + } + } + + private saveToDisk() { + try { + const obj: Record = {}; + for (const [k, v] of this.memoryStore.entries()) { + obj[k] = v; + } + const plaintext = Buffer.from(JSON.stringify(obj), 'utf8'); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv( + 'aes-256-gcm', + this.dpapiMasterKey, + iv, + ); + + const ciphertext = Buffer.concat([ + cipher.update(plaintext), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + const combined = Buffer.concat([iv, tag, ciphertext]); + fs.writeFileSync(this.storageFile, combined); + } catch (err: unknown) { + const error = err as Error; + throw new BrokerError( + 'CREDENTIAL_STORE_ERROR', + `Failed to write to DPAPI store: ${error.message}`, + ); + } + } + + get(name: string): Promise { + return Promise.resolve(this.memoryStore.get(name) || null); + } + + set(name: string, value: string): Promise { + this.memoryStore.set(name, value); + this.saveToDisk(); + return Promise.resolve(); + } + + delete(name: string): Promise { + this.memoryStore.delete(name); + this.saveToDisk(); + return Promise.resolve(); + } +} diff --git a/src/secrets/providers/broker-secret.provider.ts b/src/secrets/providers/broker-secret.provider.ts new file mode 100644 index 0000000..1fed15d --- /dev/null +++ b/src/secrets/providers/broker-secret.provider.ts @@ -0,0 +1,56 @@ +import { Injectable, Optional, Inject } from '@nestjs/common'; +import type { SecretProvider } from '../secret-provider'; +import type { BrokerTransport } from '../../../broker/src/ipc/transport'; +import { UnixSocketTransport } from '../../../broker/src/ipc/unix-socket-client'; +import { + CURRENT_PROTOCOL_VERSION, + BrokerError, + BrokerErrorCode, +} from '../../../broker/src/protocol'; + +@Injectable() +export class BrokerSecretProvider implements SecretProvider { + private readonly transport: BrokerTransport; + + constructor( + @Optional() @Inject('BROKER_TRANSPORT') transport?: BrokerTransport, + ) { + this.transport = transport || new UnixSocketTransport(); + } + + async get(name: string): Promise { + let response; + try { + response = await this.transport.request({ + version: CURRENT_PROTOCOL_VERSION, + operation: 'get', + secret: name, + }); + } catch (err: any) { + if (err instanceof BrokerError) { + throw err; + } + throw new BrokerError( + 'BROKER_UNAVAILABLE', + `Failed to reach secret broker for secret '${name}'`, + ); + } + + if (!response.success) { + const code: BrokerErrorCode = response.error || 'BROKER_UNAVAILABLE'; + throw new BrokerError( + code, + response.message || `Broker error retrieving secret '${name}'`, + ); + } + + if (typeof response.value !== 'string') { + throw new BrokerError( + 'INVALID_REQUEST', + `Broker returned malformed response for secret '${name}'`, + ); + } + + return response.value; + } +} diff --git a/src/secrets/providers/cloud-secret.provider.ts b/src/secrets/providers/cloud-secret.provider.ts new file mode 100644 index 0000000..be4914c --- /dev/null +++ b/src/secrets/providers/cloud-secret.provider.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { SecretProvider } from '../secret-provider'; + +export type CloudProviderType = 'vault' | 'aws' | 'gcp'; + +export interface CloudSecretProviderConfig { + providerType: CloudProviderType; + endpoint?: string; + vaultToken?: string; + cacheTtlMs?: number; +} + +@Injectable() +export class CloudSecretProvider implements SecretProvider { + private readonly config: CloudSecretProviderConfig; + private readonly secretCache = new Map< + string, + { value: string; expiresAt: number } + >(); + private readonly cacheTtlMs: number; + + constructor(config?: CloudSecretProviderConfig) { + const defaultProvider: CloudProviderType = + (process.env.CLOUD_SECRET_PROVIDER as CloudProviderType) || 'vault'; + this.config = config || { + providerType: defaultProvider, + endpoint: process.env.VAULT_ADDR || 'http://127.0.0.1:8200', + vaultToken: process.env.VAULT_TOKEN, + }; + this.cacheTtlMs = this.config.cacheTtlMs || 60000; // 60s cache TTL + } + + async get(name: string): Promise { + // Check in-memory TTL cache to prevent excessive network calls to Cloud Secret Manager + const cached = this.secretCache.get(name); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + let secretValue: string; + + switch (this.config.providerType) { + case 'vault': + secretValue = await this.fetchFromVault(name); + break; + case 'aws': + secretValue = await this.fetchFromAwsSecretsManager(name); + break; + case 'gcp': + secretValue = await this.fetchFromGcpSecretManager(name); + break; + default: + throw new Error( + `Unsupported Cloud Secret Provider type: ${String(this.config.providerType)}`, + ); + } + + this.secretCache.set(name, { + value: secretValue, + expiresAt: Date.now() + this.cacheTtlMs, + }); + + return secretValue; + } + + private fetchFromVault(name: string): Promise { + // Simulated HashiCorp Vault REST API integration (v1/secret/data/) + if (name === 'DATABASE_PASSWORD') { + return Promise.resolve('vault-production-db-password-987'); + } + return Promise.resolve(`vault-secret-value-for-${name}`); + } + + private fetchFromAwsSecretsManager(name: string): Promise { + // Simulated AWS SecretsManager SDK query (`GetSecretValueCommand`) + return Promise.resolve(`aws-secretsmanager-val-${name}`); + } + + private fetchFromGcpSecretManager(name: string): Promise { + // Simulated GCP SecretManager Client query (`accessSecretVersion`) + return Promise.resolve(`gcp-secret-val-${name}`); + } +} diff --git a/src/secrets/secret-provider.ts b/src/secrets/secret-provider.ts new file mode 100644 index 0000000..0a54066 --- /dev/null +++ b/src/secrets/secret-provider.ts @@ -0,0 +1,3 @@ +export interface SecretProvider { + get(name: string): Promise; +} diff --git a/src/secrets/secrets.module.ts b/src/secrets/secrets.module.ts new file mode 100644 index 0000000..05f8221 --- /dev/null +++ b/src/secrets/secrets.module.ts @@ -0,0 +1,16 @@ +import { Module, Global } from '@nestjs/common'; +import { SecretsService } from './secrets.service'; +import { BrokerSecretProvider } from './providers/broker-secret.provider'; + +@Global() +@Module({ + providers: [ + { + provide: 'SECRET_PROVIDER', + useClass: BrokerSecretProvider, + }, + SecretsService, + ], + exports: [SecretsService, 'SECRET_PROVIDER'], +}) +export class SecretsModule {} diff --git a/src/secrets/secrets.service.ts b/src/secrets/secrets.service.ts new file mode 100644 index 0000000..9dc2fa9 --- /dev/null +++ b/src/secrets/secrets.service.ts @@ -0,0 +1,14 @@ +import { Injectable, Inject } from '@nestjs/common'; +import type { SecretProvider } from './secret-provider'; + +@Injectable() +export class SecretsService { + constructor( + @Inject('SECRET_PROVIDER') + private readonly provider: SecretProvider, + ) {} + + async get(name: string): Promise { + return this.provider.get(name); + } +} diff --git a/tests/secrets/secret-provider.spec.ts b/tests/secrets/secret-provider.spec.ts new file mode 100644 index 0000000..6942f1d --- /dev/null +++ b/tests/secrets/secret-provider.spec.ts @@ -0,0 +1,57 @@ +import { SecretsService } from '../../src/secrets/secrets.service'; +import { SecretProvider } from '../../src/secrets/secret-provider'; +import { BrokerSecretProvider } from '../../src/secrets/providers/broker-secret.provider'; +import { BrokerError } from '../../broker/src/protocol'; +import { BrokerTransport } from '../../broker/src/ipc/transport'; + +describe('SecretProvider & SecretsService', () => { + it('SecretsService should delegate secret retrieval to underlying SecretProvider', async () => { + const mockGet = jest.fn().mockResolvedValue('mock-secret-val'); + const mockProvider: SecretProvider = { + get: mockGet, + }; + + const service = new SecretsService(mockProvider); + const result = await service.get('DATABASE_PASSWORD'); + + expect(result).toBe('mock-secret-val'); + expect(mockGet).toHaveBeenCalledWith('DATABASE_PASSWORD'); + }); + + it('BrokerSecretProvider should handle broker unavailable error cleanly', async () => { + const mockTransport: BrokerTransport = { + request: jest + .fn() + .mockRejectedValue( + new BrokerError('BROKER_UNAVAILABLE', 'Broker offline'), + ), + }; + + const provider = new BrokerSecretProvider(mockTransport); + + await expect(provider.get('DATABASE_PASSWORD')).rejects.toThrow( + BrokerError, + ); + await expect(provider.get('DATABASE_PASSWORD')).rejects.toMatchObject({ + code: 'BROKER_UNAVAILABLE', + }); + }); + + it('BrokerSecretProvider should handle ACCESS_DENIED cleanly', async () => { + const mockTransport: BrokerTransport = { + request: jest.fn().mockResolvedValue({ + version: 1, + success: false, + error: 'ACCESS_DENIED', + message: 'Access denied to secret', + }), + }; + + const provider = new BrokerSecretProvider(mockTransport); + + await expect(provider.get('FORBIDDEN_SECRET')).rejects.toThrow(BrokerError); + await expect(provider.get('FORBIDDEN_SECRET')).rejects.toMatchObject({ + code: 'ACCESS_DENIED', + }); + }); +});