From 733f3b6611596f04580d1076c47de8ee98d6c530 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 21 Apr 2026 22:02:59 -0500 Subject: [PATCH] feat(federation): seal federation peer client keys at rest (FED-M2-05) - Add packages/auth/src/seal.ts: shared AES-256-GCM seal/unseal using BETTER_AUTH_SECRET - Export seal/unseal from @mosaicstack/auth index - Refactor provider-credentials.service.ts to import seal/unseal from @mosaicstack/auth - Add apps/gateway/src/federation/peer-key.util.ts: sealClientKey/unsealClientKey wrappers - Add peer-key.spec.ts with 5 vitest tests (round-trip, non-determinism, at-rest, tamper, missing secret) - Document key rotation deferred procedure in docs/federation/SETUP.md Co-Authored-By: Claude Sonnet 4.6 --- .../src/agent/provider-credentials.service.ts | 58 +---------------- .../src/federation/__tests__/peer-key.spec.ts | 63 +++++++++++++++++++ apps/gateway/src/federation/peer-key.util.ts | 9 +++ docs/federation/SETUP.md | 6 ++ packages/auth/src/index.ts | 1 + packages/auth/src/seal.ts | 52 +++++++++++++++ 6 files changed, 134 insertions(+), 55 deletions(-) create mode 100644 apps/gateway/src/federation/__tests__/peer-key.spec.ts create mode 100644 apps/gateway/src/federation/peer-key.util.ts create mode 100644 packages/auth/src/seal.ts diff --git a/apps/gateway/src/agent/provider-credentials.service.ts b/apps/gateway/src/agent/provider-credentials.service.ts index 60fdb9c..8f61ec8 100644 --- a/apps/gateway/src/agent/provider-credentials.service.ts +++ b/apps/gateway/src/agent/provider-credentials.service.ts @@ -1,62 +1,10 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import { seal, unseal } from '@mosaicstack/auth'; import type { Db } from '@mosaicstack/db'; import { providerCredentials, eq, and } from '@mosaicstack/db'; import { DB } from '../database/database.module.js'; import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js'; -const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 12; // 96-bit IV for GCM -const TAG_LENGTH = 16; // 128-bit auth tag - -/** - * Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256. - * The secret is assumed to be set in the environment. - */ -function deriveEncryptionKey(): Buffer { - const secret = process.env['BETTER_AUTH_SECRET']; - if (!secret) { - throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key'); - } - return createHash('sha256').update(secret).digest(); -} - -/** - * Encrypt a plain-text value using AES-256-GCM. - * Output format: base64(iv + authTag + ciphertext) - */ -function encrypt(plaintext: string): string { - const key = deriveEncryptionKey(); - const iv = randomBytes(IV_LENGTH); - const cipher = createCipheriv(ALGORITHM, key, iv); - - const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); - const authTag = cipher.getAuthTag(); - - // Combine iv (12) + authTag (16) + ciphertext and base64-encode - const combined = Buffer.concat([iv, authTag, encrypted]); - return combined.toString('base64'); -} - -/** - * Decrypt a value encrypted by `encrypt()`. - * Throws on authentication failure (tampered data). - */ -function decrypt(encoded: string): string { - const key = deriveEncryptionKey(); - const combined = Buffer.from(encoded, 'base64'); - - const iv = combined.subarray(0, IV_LENGTH); - const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); - const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH); - - const decipher = createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(authTag); - - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); - return decrypted.toString('utf8'); -} - @Injectable() export class ProviderCredentialsService { private readonly logger = new Logger(ProviderCredentialsService.name); @@ -74,7 +22,7 @@ export class ProviderCredentialsService { value: string, metadata?: Record, ): Promise { - const encryptedValue = encrypt(value); + const encryptedValue = seal(value); await this.db .insert(providerCredentials) @@ -122,7 +70,7 @@ export class ProviderCredentialsService { } try { - return decrypt(row.encryptedValue); + return unseal(row.encryptedValue); } catch (err) { this.logger.error( `Failed to decrypt credential for user=${userId} provider=${provider}`, diff --git a/apps/gateway/src/federation/__tests__/peer-key.spec.ts b/apps/gateway/src/federation/__tests__/peer-key.spec.ts new file mode 100644 index 0000000..26d196f --- /dev/null +++ b/apps/gateway/src/federation/__tests__/peer-key.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sealClientKey, unsealClientKey } from '../peer-key.util.js'; + +const TEST_SECRET = 'test-secret-for-peer-key-unit-tests-only'; + +const TEST_PEM = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TB3wo +pCOW8QqstpxEBpnFo37JxLYEJbpE3gUlJajsHv9UWRQ7m5B7n+MBXwTCQqMEY8Wl +kHv9tGgz1YGwzBjNKxPJXE6pPTXQ1Oa0VB9l3qHdqF5HtZoJzE0c6dO8HJ5YUVL +-----END PRIVATE KEY-----`; + +let savedSecret: string | undefined; + +beforeEach(() => { + savedSecret = process.env['BETTER_AUTH_SECRET']; + process.env['BETTER_AUTH_SECRET'] = TEST_SECRET; +}); + +afterEach(() => { + if (savedSecret === undefined) { + delete process.env['BETTER_AUTH_SECRET']; + } else { + process.env['BETTER_AUTH_SECRET'] = savedSecret; + } +}); + +describe('peer-key seal/unseal', () => { + it('round-trip: unsealClientKey(sealClientKey(pem)) returns original pem', () => { + const sealed = sealClientKey(TEST_PEM); + const roundTripped = unsealClientKey(sealed); + expect(roundTripped).toBe(TEST_PEM); + }); + + it('non-determinism: sealClientKey produces different ciphertext each call', () => { + const sealed1 = sealClientKey(TEST_PEM); + const sealed2 = sealClientKey(TEST_PEM); + expect(sealed1).not.toBe(sealed2); + }); + + it('at-rest: sealed output does not contain plaintext PEM content', () => { + const sealed = sealClientKey(TEST_PEM); + expect(sealed).not.toContain('PRIVATE KEY'); + expect(sealed).not.toContain( + 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TB3wo', + ); + }); + + it('tamper: flipping a byte in the sealed payload causes unseal to throw', () => { + const sealed = sealClientKey(TEST_PEM); + const buf = Buffer.from(sealed, 'base64'); + // Flip a byte in the middle of the buffer (past IV and authTag) + const midpoint = Math.floor(buf.length / 2); + buf[midpoint] = buf[midpoint]! ^ 0xff; + const tampered = buf.toString('base64'); + expect(() => unsealClientKey(tampered)).toThrow(); + }); + + it('missing secret: unsealClientKey throws when BETTER_AUTH_SECRET is unset', () => { + const sealed = sealClientKey(TEST_PEM); + delete process.env['BETTER_AUTH_SECRET']; + expect(() => unsealClientKey(sealed)).toThrow('BETTER_AUTH_SECRET is not set'); + }); +}); diff --git a/apps/gateway/src/federation/peer-key.util.ts b/apps/gateway/src/federation/peer-key.util.ts new file mode 100644 index 0000000..94f2ac8 --- /dev/null +++ b/apps/gateway/src/federation/peer-key.util.ts @@ -0,0 +1,9 @@ +import { seal, unseal } from '@mosaicstack/auth'; + +export function sealClientKey(privateKeyPem: string): string { + return seal(privateKeyPem); +} + +export function unsealClientKey(sealedKey: string): string { + return unseal(sealedKey); +} diff --git a/docs/federation/SETUP.md b/docs/federation/SETUP.md index 5b3f486..38ec2e7 100644 --- a/docs/federation/SETUP.md +++ b/docs/federation/SETUP.md @@ -117,3 +117,9 @@ docker compose -f docker-compose.federated.yml logs valkey-federated ``` If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`. + +## Key rotation (deferred) + +Federation peer private keys (`federation_peers.client_key_pem`) are sealed at rest using AES-256-GCM with a key derived from `BETTER_AUTH_SECRET` via SHA-256. If `BETTER_AUTH_SECRET` is rotated, all sealed `client_key_pem` values in the database become unreadable and must be re-sealed with the new key before rotation completes. + +The full key rotation procedure (decrypt all rows with old key, re-encrypt with new key, atomically swap the secret) is out of scope for M2. Operators must not rotate `BETTER_AUTH_SECRET` without a migration plan for all sealed federation peer keys. diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 77867f4..9922a4e 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -10,3 +10,4 @@ export { type SsoTeamSyncConfig, type SupportedSsoProviderId, } from './sso.js'; +export { seal, unseal } from './seal.js'; diff --git a/packages/auth/src/seal.ts b/packages/auth/src/seal.ts new file mode 100644 index 0000000..23a4a04 --- /dev/null +++ b/packages/auth/src/seal.ts @@ -0,0 +1,52 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; // 96-bit IV for GCM +const TAG_LENGTH = 16; // 128-bit auth tag + +/** + * Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256. + * Throws if BETTER_AUTH_SECRET is not set. + */ +function deriveKey(): Buffer { + const secret = process.env['BETTER_AUTH_SECRET']; + if (!secret) { + throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key'); + } + return createHash('sha256').update(secret).digest(); +} + +/** + * Seal a plaintext string using AES-256-GCM. + * Output format: base64(IV || authTag || ciphertext) + */ +export function seal(plaintext: string): string { + const key = deriveKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + const combined = Buffer.concat([iv, authTag, encrypted]); + return combined.toString('base64'); +} + +/** + * Unseal a value sealed by `seal()`. + * Throws on authentication failure (tampered data) or if BETTER_AUTH_SECRET is unset. + */ +export function unseal(encoded: string): string { + const key = deriveKey(); + const combined = Buffer.from(encoded, 'base64'); + + const iv = combined.subarray(0, IV_LENGTH); + const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return decrypted.toString('utf8'); +}