Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ STORAGE_DRIVER=local
# S3_FORCE_PATH_STYLE=true # true for MinIO; false for AWS S3
# S3_PUBLIC_BASE_URL= # optional CDN/public base for object URLs

# Migration source-PMS credential vault (AES-256-GCM at rest)
# Generate: openssl rand -hex 32
# MIGRATION_CREDENTIAL_ENCRYPTION_KEY=
# MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID=default
# Optional rotation map — JSON object of keyId → 64-char hex key
# MIGRATION_CREDENTIAL_ENCRYPTION_KEYS={"legacy":"<64 hex chars>"}

# Channex channel manager (optional env fallbacks; prefer connection.config)
# CHANNEX_API_KEY=
# CHANNEX_PROPERTY_ID=
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
<img src="https://img.shields.io/badge/Tests-1458%20passing-brightgreen" alt="1458 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
<img src="https://img.shields.io/badge/Tests-1474%20passing-brightgreen" alt="1474 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

<p align="center">
Expand Down Expand Up @@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire
| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
| Package Manager | pnpm workspaces | Monorepo management |
| Testing | Vitest (1458 tests across 206 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Testing | Vitest (1474 tests across 209 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Containers | Docker + docker-compose | Local dev and production deployment |
| CI/CD | GitHub Actions | Automated testing, builds, and releases |

Expand Down Expand Up @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests

```bash
# All tests (1458 tests across 206 test files)
# All tests (1474 tests across 209 test files)

# API tests only
pnpm --filter @telivityhaip/api test
Expand Down Expand Up @@ -1188,7 +1188,7 @@ HAIP is built in public and contributions are welcome.
pnpm install # Install dependencies
pnpm build # Build all workspace packages
pnpm dev # Start API in dev mode (hot reload)
pnpm test # Run all tests (1458 tests, 206 files)
pnpm test # Run all tests (1474 tests, 209 files)
pnpm lint # ESLint
```

Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { LoyaltyModule } from './modules/loyalty/loyalty.module';
import { IntegrationsModule } from './modules/integrations/integrations.module';
import { IcalModule } from './modules/ical/ical.module';
import { FiscalModule } from './modules/fiscal/fiscal.module';
import { MigrationModule } from './modules/migration/migration.module';

const imports: any[] = [
ConfigModule.forRoot({
Expand Down Expand Up @@ -106,6 +107,7 @@ const imports: any[] = [
IntegrationsModule,
IcalModule,
FiscalModule,
MigrationModule,
];

// Serve the bundled dashboard as static files. Enabled in production, or
Expand Down
83 changes: 83 additions & 0 deletions apps/api/src/common/crypto/credential-encryption.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { randomBytes } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import {
CredentialEncryptionError,
decryptCredentialPlaintext,
encryptCredentialPlaintext,
loadMigrationCredentialKeyRingFromEnv,
parseAes256KeyHex,
serializeEncryptedBlob,
deserializeEncryptedBlob,
} from './credential-encryption';

const KEY_A = randomBytes(32).toString('hex');
const KEY_B = randomBytes(32).toString('hex');

function env(overrides: Record<string, string> = {}): NodeJS.ProcessEnv {
return {
MIGRATION_CREDENTIAL_ENCRYPTION_KEY: KEY_A,
MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID: 'default',
...overrides,
};
}

describe('credential-encryption', () => {
it('round-trips plaintext', () => {
const keyRing = loadMigrationCredentialKeyRingFromEnv(env());
const blob = encryptCredentialPlaintext('{"apiKey":"secret"}', keyRing, env());
const out = decryptCredentialPlaintext(blob, keyRing);
expect(out).toBe('{"apiKey":"secret"}');
});

it('fails closed when decrypting with a wrong key ring', () => {
const keyRing = loadMigrationCredentialKeyRingFromEnv(env());
const blob = encryptCredentialPlaintext('sensitive', keyRing, env());
const wrongRing = loadMigrationCredentialKeyRingFromEnv(
env({ MIGRATION_CREDENTIAL_ENCRYPTION_KEY: KEY_B }),
);
expect(() => decryptCredentialPlaintext(blob, wrongRing)).toThrow(CredentialEncryptionError);
});

it('fails closed when the GCM auth tag is tampered', () => {
const keyRing = loadMigrationCredentialKeyRingFromEnv(env());
const blob = encryptCredentialPlaintext('sensitive', keyRing, env());
const tampered = { ...blob, authTag: '0'.repeat(blob.authTag.length) };
expect(() => decryptCredentialPlaintext(tampered, keyRing)).toThrow(CredentialEncryptionError);
});

it('fails closed when ciphertext is tampered', () => {
const keyRing = loadMigrationCredentialKeyRingFromEnv(env());
const blob = encryptCredentialPlaintext('sensitive', keyRing, env());
const tampered = { ...blob, ciphertext: blob.ciphertext.replace(/a/g, 'b') };
expect(() => decryptCredentialPlaintext(tampered, keyRing)).toThrow(CredentialEncryptionError);
});

it('decrypts with a rotated legacy key id from the key ring', () => {
const legacyEnv = env({
MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID: 'legacy',
MIGRATION_CREDENTIAL_ENCRYPTION_KEY: KEY_B,
MIGRATION_CREDENTIAL_ENCRYPTION_KEYS: JSON.stringify({ legacy: KEY_B }),
});
const legacyRing = loadMigrationCredentialKeyRingFromEnv(legacyEnv);
const blob = encryptCredentialPlaintext('rotated-secret', legacyRing, legacyEnv);
expect(blob.keyId).toBe('legacy');

const currentRing = loadMigrationCredentialKeyRingFromEnv(
env({
MIGRATION_CREDENTIAL_ENCRYPTION_KEYS: JSON.stringify({ legacy: KEY_B }),
}),
);
expect(decryptCredentialPlaintext(blob, currentRing)).toBe('rotated-secret');
});

it('serializes and deserializes blobs for DB storage', () => {
const keyRing = loadMigrationCredentialKeyRingFromEnv(env());
const blob = encryptCredentialPlaintext('x', keyRing, env());
const roundTrip = deserializeEncryptedBlob(serializeEncryptedBlob(blob));
expect(roundTrip).toEqual(blob);
});

it('rejects invalid key hex length', () => {
expect(() => parseAes256KeyHex('abcd', 'test')).toThrow(CredentialEncryptionError);
});
});
175 changes: 175 additions & 0 deletions apps/api/src/common/crypto/credential-encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

/** AES-256-GCM encrypted blob with key-rotation metadata. */
export interface EncryptedCredentialBlob {
keyId: string;
iv: string;
ciphertext: string;
authTag: string;
}

export type CredentialKeyRing = ReadonlyMap<string, Buffer>;

const AES_ALGO = 'aes-256-gcm';
const IV_BYTES = 16;
const KEY_BYTES = 32;

export class CredentialEncryptionError extends Error {
constructor(message: string) {
super(message);
this.name = 'CredentialEncryptionError';
}
}

/** Parse a 64-char hex string into a 32-byte AES-256 key. */
export function parseAes256KeyHex(hex: string, label: string): Buffer {
const key = Buffer.from(hex, 'hex');
if (key.length !== KEY_BYTES) {
throw new CredentialEncryptionError(
`${label} must be ${KEY_BYTES * 2} hex characters (${KEY_BYTES} bytes)`,
);
}
return key;
}

/**
* Load encryption keys from env for migration source-PMS credentials.
*
* - `MIGRATION_CREDENTIAL_ENCRYPTION_KEY` — primary key (hex)
* - `MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID` — id for the primary key (default: "default")
* - `MIGRATION_CREDENTIAL_ENCRYPTION_KEYS` — optional JSON map of keyId → hex for rotation
*/
export function loadMigrationCredentialKeyRingFromEnv(
env: NodeJS.ProcessEnv = process.env,
): CredentialKeyRing {
const keys = new Map<string, Buffer>();
const primaryHex = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEY'];
const primaryId = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID'] ?? 'default';
if (primaryHex) {
keys.set(primaryId, parseAes256KeyHex(primaryHex, 'MIGRATION_CREDENTIAL_ENCRYPTION_KEY'));
}

const extraJson = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEYS'];
if (extraJson) {
let parsed: unknown;
try {
parsed = JSON.parse(extraJson);
} catch {
throw new CredentialEncryptionError(
'MIGRATION_CREDENTIAL_ENCRYPTION_KEYS must be valid JSON',
);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new CredentialEncryptionError(
'MIGRATION_CREDENTIAL_ENCRYPTION_KEYS must be a JSON object',
);
}
for (const [id, hex] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof hex !== 'string') {
throw new CredentialEncryptionError(
`MIGRATION_CREDENTIAL_ENCRYPTION_KEYS["${id}"] must be a hex string`,
);
}
keys.set(id, parseAes256KeyHex(hex, `MIGRATION_CREDENTIAL_ENCRYPTION_KEYS["${id}"]`));
}
}

return keys;
}

/** Resolve the active key id + material for new encryptions. */
export function resolveActiveMigrationCredentialKey(
keyRing: CredentialKeyRing,
env: NodeJS.ProcessEnv = process.env,
): { keyId: string; key: Buffer } {
if (keyRing.size === 0) {
throw new CredentialEncryptionError(
'MIGRATION_CREDENTIAL_ENCRYPTION_KEY is not configured',
);
}
const activeId = env['MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID'] ?? 'default';
const key = keyRing.get(activeId);
if (!key) {
throw new CredentialEncryptionError(
`Active encryption key id "${activeId}" is not in the key ring`,
);
}
return { keyId: activeId, key };
}

/** Encrypt a UTF-8 plaintext string with AES-256-GCM. */
export function encryptCredentialPlaintext(
plaintext: string,
keyRing: CredentialKeyRing,
env: NodeJS.ProcessEnv = process.env,
): EncryptedCredentialBlob {
const { keyId, key } = resolveActiveMigrationCredentialKey(keyRing, env);
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(AES_ALGO, key, iv);
let ciphertext = cipher.update(plaintext, 'utf8', 'hex');
ciphertext += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return {
keyId,
iv: iv.toString('hex'),
ciphertext,
authTag,
};
}

/** Decrypt an AES-256-GCM blob. Fails closed on wrong/missing key or tampered ciphertext. */
export function decryptCredentialPlaintext(
blob: EncryptedCredentialBlob,
keyRing: CredentialKeyRing,
): string {
const key = keyRing.get(blob.keyId);
if (!key) {
throw new CredentialEncryptionError(
`Encryption key id "${blob.keyId}" is not available`,
);
}
const decipher = createDecipheriv(AES_ALGO, key, Buffer.from(blob.iv, 'hex'));
decipher.setAuthTag(Buffer.from(blob.authTag, 'hex'));
try {
let plaintext = decipher.update(blob.ciphertext, 'hex', 'utf8');
plaintext += decipher.final('utf8');
return plaintext;
} catch {
throw new CredentialEncryptionError('Credential decryption failed (wrong key or tampered data)');
}
}

/** Serialize blob columns for DB storage (single text column). */
export function serializeEncryptedBlob(blob: EncryptedCredentialBlob): string {
return JSON.stringify(blob);
}

/** Deserialize blob from DB storage. */
export function deserializeEncryptedBlob(serialized: string): EncryptedCredentialBlob {
let parsed: unknown;
try {
parsed = JSON.parse(serialized);
} catch {
throw new CredentialEncryptionError('Stored credential ciphertext is not valid JSON');
}
if (
parsed === null ||
typeof parsed !== 'object' ||
!('keyId' in parsed) ||
!('iv' in parsed) ||
!('ciphertext' in parsed) ||
!('authTag' in parsed)
) {
throw new CredentialEncryptionError('Stored credential ciphertext is missing required fields');
}
const blob = parsed as EncryptedCredentialBlob;
if (
typeof blob.keyId !== 'string' ||
typeof blob.iv !== 'string' ||
typeof blob.ciphertext !== 'string' ||
typeof blob.authTag !== 'string'
) {
throw new CredentialEncryptionError('Stored credential ciphertext has invalid field types');
}
return blob;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { IsIn, IsObject } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { MIGRATION_SOURCE_PMS } from '@telivityhaip/shared';

export class UpsertMigrationCredentialDto {
@ApiProperty({ enum: MIGRATION_SOURCE_PMS, example: 'mews' })
@IsIn([...MIGRATION_SOURCE_PMS])
sourcePms!: (typeof MIGRATION_SOURCE_PMS)[number];

@ApiProperty({
description:
'Source-PMS credential payload (API keys, tokens). Encrypted at rest; never returned by the API.',
example: { clientToken: '***', accessToken: '***' },
})
@IsObject()
credentials!: Record<string, unknown>;
}
Loading
Loading