All files / lib/crypto token-encryption.ts

13.2% Statements 7/53
100% Branches 0/0
0% Functions 0/3
13.2% Lines 7/53

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 851x                   1x 1x 1x 1x                                     1x                                     1x                                                                  
/**
 * Token Encryption Utility
 *
 * AES-256-GCM encryption for sensitive tokens (OAuth access tokens, etc.)
 * Uses TOKEN_ENCRYPTION_KEY env var (64-char hex = 32 bytes).
 * Falls back to plaintext storage in development if key is not set.
 */
 
import crypto from 'crypto';
 
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit IV recommended for GCM
const AUTH_TAG_LENGTH = 16;
const ENCRYPTED_PREFIX = 'enc:';
 
function getEncryptionKey(): Buffer | null {
  const key = process.env.TOKEN_ENCRYPTION_KEY;
  if (!key) return null;
  if (key.length !== 64) {
    console.warn(
      '[token-encryption] TOKEN_ENCRYPTION_KEY must be 64 hex characters (32 bytes). Falling back to plaintext.'
    );
    return null;
  }
  return Buffer.from(key, 'hex');
}
 
/**
 * Encrypt a token string using AES-256-GCM.
 * Returns prefixed ciphertext: "enc:<iv>:<authTag>:<ciphertext>" (all base64).
 * Falls back to plaintext if TOKEN_ENCRYPTION_KEY is not set.
 */
export function encryptToken(plaintext: string): string {
  const key = getEncryptionKey();
  if (!key) return plaintext;
 
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv, {
    authTagLength: AUTH_TAG_LENGTH,
  });
 
  const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  const authTag = cipher.getAuthTag();
 
  return `${ENCRYPTED_PREFIX}${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
}
 
/**
 * Decrypt a token string encrypted with encryptToken().
 * Handles both encrypted (prefixed) and legacy plaintext tokens.
 */
export function decryptToken(ciphertext: string): string {
  // Handle legacy plaintext tokens (not prefixed)
  if (!ciphertext.startsWith(ENCRYPTED_PREFIX)) {
    return ciphertext;
  }
 
  const key = getEncryptionKey();
  if (!key) {
    console.warn(
      '[token-encryption] Cannot decrypt: TOKEN_ENCRYPTION_KEY not set. Returning raw value.'
    );
    // Strip prefix and return raw (this shouldn't happen in production)
    return ciphertext.slice(ENCRYPTED_PREFIX.length);
  }
 
  const parts = ciphertext.slice(ENCRYPTED_PREFIX.length).split(':');
  if (parts.length !== 3) {
    throw new Error('Invalid encrypted token format');
  }
 
  const [ivB64, authTagB64, encryptedB64] = parts;
  const iv = Buffer.from(ivB64, 'base64');
  const authTag = Buffer.from(authTagB64, 'base64');
  const encrypted = Buffer.from(encryptedB64, 'base64');
 
  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
    authTagLength: AUTH_TAG_LENGTH,
  });
  decipher.setAuthTag(authTag);
 
  const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
  return decrypted.toString('utf8');
}