ddd628ab6f
IIOS becomes the tenant's integration/credential hub for anything it sends. New IiosProviderCredential (one row per scope+providerType) seals the secret with AES-256-GCM under IIOS_CRED_KEY (secret-crypto.ts); only non-secret displayHints are ever read back. ProviderCredentialService upsert/resolve/status; TwilioSmsProvider resolves the caller's own creds per scope at send time and POSTs to Twilio (fail-closed NOT_CONFIGURED when unset). Registered over the SMS sandbox only when the platform key + store are present. PUT/GET /v1/providers/:type/credentials (scope-fenced, masked reads). 16 new unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
47 lines
1.8 KiB
TypeScript
47 lines
1.8 KiB
TypeScript
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
|
|
/**
|
|
* Envelope for a tenant secret sealed with AES-256-GCM. Stored as base64 columns on
|
|
* IiosProviderCredential; the plaintext (a provider config JSON string) never touches disk.
|
|
*/
|
|
export interface SealedSecret {
|
|
cipherText: string;
|
|
iv: string;
|
|
authTag: string;
|
|
keyVersion: number;
|
|
}
|
|
|
|
/** Bumped only when the platform master key rotates; lets old rows decrypt under an old key. */
|
|
export const SECRET_KEY_VERSION = 1;
|
|
|
|
/**
|
|
* Resolve the 32-byte platform master key from `IIOS_CRED_KEY` (base64). Returns null when
|
|
* unset (so egress providers that need it stay unregistered / fail closed) but THROWS on a
|
|
* present-but-malformed key — a wrong-length key is an operator error, not a "disabled" state.
|
|
*/
|
|
export function credKeyFromEnv(env: NodeJS.ProcessEnv = process.env): Buffer | null {
|
|
const raw = env.IIOS_CRED_KEY;
|
|
if (!raw) return null;
|
|
const key = Buffer.from(raw, 'base64');
|
|
if (key.length !== 32) throw new Error('IIOS_CRED_KEY must decode to 32 bytes (base64-encoded 256-bit key)');
|
|
return key;
|
|
}
|
|
|
|
export function sealSecret(plaintext: string, key: Buffer): SealedSecret {
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
return {
|
|
cipherText: enc.toString('base64'),
|
|
iv: iv.toString('base64'),
|
|
authTag: cipher.getAuthTag().toString('base64'),
|
|
keyVersion: SECRET_KEY_VERSION,
|
|
};
|
|
}
|
|
|
|
export function openSecret(sealed: SealedSecret, key: Buffer): string {
|
|
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(sealed.iv, 'base64'));
|
|
decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64'));
|
|
return Buffer.concat([decipher.update(Buffer.from(sealed.cipherText, 'base64')), decipher.final()]).toString('utf8');
|
|
}
|