Jelajahi Sumber

Token Encryption

Benjamin Harris 1 bulan lalu
induk
melakukan
adedf37766

+ 6 - 0
.env.example

@@ -3,6 +3,12 @@
 # what is registered in your Facebook Developer App > Valid OAuth Redirect URIs.
 APP_BASE_URL=http://localhost:8081
 
+# ─── Encryption ────────────────────────────────────────────────────────────────
+# 64-character hex string (32 bytes) used to encrypt OAuth tokens at rest.
+# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
+# Without this key tokens are stored in plaintext (insecure).
+ENCRYPTION_KEY=
+
 # ─── RabbitMQ ──────────────────────────────────────────────────────────────────
 RABBITMQ_URL=amqp://username:password@messageBroker:5672
 

+ 5 - 1
services/facebook/index.js

@@ -2,6 +2,7 @@ require('dotenv').config();
 const axios = require('axios');
 const BasePlatformService = require('./utils/BasePlatformService');
 const { getDb } = require('./utils/MongoDBConnector');
+const { decryptToken, warnIfNoKey } = require('./utils/crypto');
 
 const GRAPH_API = 'https://graph.facebook.com/v22.0';
 
@@ -17,7 +18,9 @@ class FacebookService extends BasePlatformService {
       const db = await getDb();
       const cred = await db.collection('platform_credentials').findOne({ _id: 'facebook' });
       const dbPages = (cred?.pages || []).filter((p) => p.selected);
-      if (dbPages.length > 0) return dbPages;
+      if (dbPages.length > 0) {
+        return dbPages.map((p) => ({ ...p, accessToken: decryptToken(p.accessToken) })).filter((p) => p.accessToken);
+      }
     } catch (_) { /* fall through */ }
 
     // Env var fallback (legacy single-page mode)
@@ -133,4 +136,5 @@ class FacebookService extends BasePlatformService {
 }
 
 const service = new FacebookService();
+warnIfNoKey('facebook');
 service.start(process.env.PORT || 3006);

+ 16 - 8
services/gateway/server.js

@@ -10,6 +10,7 @@ const crypto = require('crypto');
 const { pipeline } = require('stream/promises');
 const { ObjectId } = require('mongodb');
 const { getDb } = require('./utils/MongoDBConnector');
+const { encryptToken, decryptToken, warnIfNoKey } = require('./utils/crypto');
 const RabbitMQProducer = require('./utils/RabbitMQProducer');
 
 const UPLOAD_DIR = process.env.UPLOAD_DIR || '/uploads';
@@ -198,18 +199,22 @@ app.get('/meta/token-expiry', async (request, reply) => {
 
   const appCred = await getCredentials('meta_app');
   if (!appCred?.appId || !appCred?.appSecret) return { accounts: [] };
+  const plainAppSecret = decryptToken(appCred.appSecret);
+  if (!plainAppSecret) return { accounts: [] };
 
   const ig = await getCredentials('instagram');
   const selectedAccounts = (ig?.accounts || []).filter((a) => a.selected && a.accessToken);
   if (!selectedAccounts.length) return { accounts: [] };
 
-  const appToken = `${appCred.appId}|${appCred.appSecret}`;
+  const appToken = `${appCred.appId}|${plainAppSecret}`;
   const accounts = [];
 
   for (const account of selectedAccounts) {
+    const plainToken = decryptToken(account.accessToken);
+    if (!plainToken) continue;
     try {
       const res = await axios.get(`${GRAPH_API}/debug_token`, {
-        params: { input_token: account.accessToken, access_token: appToken },
+        params: { input_token: plainToken, access_token: appToken },
         timeout: 10000,
       });
       const data = res.data.data;
@@ -450,7 +455,7 @@ app.post('/credentials/meta-app', async (request, reply) => {
   if (!appId || !appSecret) {
     return reply.code(400).send({ error: 'appId and appSecret are required' });
   }
-  await setCredentials('meta_app', { appId, appSecret });
+  await setCredentials('meta_app', { appId, appSecret: encryptToken(appSecret) });
   return { success: true };
 });
 
@@ -458,7 +463,8 @@ app.post('/credentials/meta-app', async (request, reply) => {
 app.get('/credentials/meta-app', async () => {
   const cred = await getCredentials('meta_app');
   if (!cred) return { configured: false };
-  return { configured: true, appId: cred.appId, appSecretHint: `****${cred.appSecret.slice(-4)}` };
+  const plainSecret = decryptToken(cred.appSecret) || '';
+  return { configured: true, appId: cred.appId, appSecretHint: plainSecret ? `****${plainSecret.slice(-4)}` : '****' };
 });
 
 // ─── Meta OAuth Flow ──────────────────────────────────────────────────────────
@@ -498,6 +504,8 @@ app.get('/auth/meta/callback', async (request, reply) => {
   try {
     const appCred = await getCredentials('meta_app');
     if (!appCred?.appId) throw new Error('App credentials not configured');
+    const appSecret = decryptToken(appCred.appSecret);
+    if (!appSecret) throw new Error('Failed to decrypt app secret');
 
     const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
 
@@ -505,7 +513,7 @@ app.get('/auth/meta/callback', async (request, reply) => {
     const shortRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
       params: {
         client_id: appCred.appId,
-        client_secret: appCred.appSecret,
+        client_secret: appSecret,
         redirect_uri: redirectUri,
         code,
       },
@@ -516,7 +524,7 @@ app.get('/auth/meta/callback', async (request, reply) => {
       params: {
         grant_type: 'fb_exchange_token',
         client_id: appCred.appId,
-        client_secret: appCred.appSecret,
+        client_secret: appSecret,
         fb_exchange_token: shortRes.data.access_token,
       },
     });
@@ -534,7 +542,7 @@ app.get('/auth/meta/callback', async (request, reply) => {
       pages.push({
         id: page.id,
         name: page.name,
-        accessToken: page.access_token,
+        accessToken: encryptToken(page.access_token),
         picture: page.picture?.data?.url || null,
         selected: false,
       });
@@ -561,7 +569,7 @@ app.get('/auth/meta/callback', async (request, reply) => {
             username: igProfile.data.username || igProfile.data.name,
             name: igProfile.data.name,
             avatar: igProfile.data.profile_picture_url || null,
-            accessToken: userToken,
+            accessToken: encryptToken(userToken),
             pageId: page.id,
             selected: false,
           });

+ 2 - 0
services/gateway/start.js

@@ -1,8 +1,10 @@
 require('dotenv').config();
 const app = require('./server.js');
 const { connect } = require('./utils/MongoDBConnector');
+const { warnIfNoKey } = require('./utils/crypto');
 
 async function start() {
+  warnIfNoKey('gateway');
   await connect();
   await app.listen({ port: 8084, host: '0.0.0.0' });
   app.log.info({ action: 'service_start', port: 8084, outcome: 'success' }, 'Gateway API running');

+ 5 - 1
services/instagram/index.js

@@ -2,6 +2,7 @@ require('dotenv').config();
 const axios = require('axios');
 const BasePlatformService = require('./utils/BasePlatformService');
 const { getDb } = require('./utils/MongoDBConnector');
+const { decryptToken, warnIfNoKey } = require('./utils/crypto');
 
 const GRAPH_API = 'https://graph.facebook.com/v22.0';
 
@@ -17,7 +18,9 @@ class InstagramService extends BasePlatformService {
       const db = await getDb();
       const cred = await db.collection('platform_credentials').findOne({ _id: 'instagram' });
       const dbAccounts = (cred?.accounts || []).filter((a) => a.selected);
-      if (dbAccounts.length > 0) return dbAccounts;
+      if (dbAccounts.length > 0) {
+        return dbAccounts.map((a) => ({ ...a, accessToken: decryptToken(a.accessToken) })).filter((a) => a.accessToken);
+      }
     } catch (_) { /* fall through */ }
 
     // Env var fallback (legacy single-account mode)
@@ -159,4 +162,5 @@ class InstagramService extends BasePlatformService {
 }
 
 const service = new InstagramService();
+warnIfNoKey('instagram');
 service.start(process.env.PORT || 3005);

+ 63 - 0
services/utils/crypto.js

@@ -0,0 +1,63 @@
+const { createCipheriv, createDecipheriv, randomBytes } = require('crypto');
+const { createLogger } = require('./logger');
+
+const ALGORITHM = 'aes-256-gcm';
+const ENC_PREFIX = 'ENC:v1:';
+const log = createLogger('crypto');
+
+function _getKey() {
+  const keyHex = process.env.ENCRYPTION_KEY;
+  if (!keyHex || keyHex.length !== 64) return null;
+  return Buffer.from(keyHex, 'hex');
+}
+
+// Returns the ciphertext string, or plaintext if key not configured.
+function encryptToken(plaintext) {
+  if (!plaintext) return plaintext;
+  if (String(plaintext).startsWith(ENC_PREFIX)) return plaintext; // already encrypted
+  const key = _getKey();
+  if (!key) return plaintext;
+
+  const iv = randomBytes(12);
+  const cipher = createCipheriv(ALGORITHM, key, iv);
+  const encrypted = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
+  const tag = cipher.getAuthTag();
+  return `${ENC_PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
+}
+
+// Returns decrypted plaintext, or the original value if not encrypted.
+// Returns null and logs an error if decryption fails.
+function decryptToken(value) {
+  if (!value) return value;
+  if (!String(value).startsWith(ENC_PREFIX)) return value; // plaintext passthrough (legacy data)
+
+  const key = _getKey();
+  if (!key) {
+    log.error({ action: 'decrypt', outcome: 'failure', err: 'ENCRYPTION_KEY not set — cannot decrypt stored token' });
+    return null;
+  }
+
+  try {
+    const parts = String(value).slice(ENC_PREFIX.length).split(':');
+    if (parts.length !== 3) throw new Error('malformed ciphertext');
+    const [ivHex, tagHex, ciphertextHex] = parts;
+    const iv = Buffer.from(ivHex, 'hex');
+    const tag = Buffer.from(tagHex, 'hex');
+    const ciphertext = Buffer.from(ciphertextHex, 'hex');
+    const decipher = createDecipheriv(ALGORITHM, key, iv);
+    decipher.setAuthTag(tag);
+    return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
+  } catch (err) {
+    log.error({ action: 'decrypt', outcome: 'failure', err: err.message });
+    return null;
+  }
+}
+
+// Log a startup warning when no key is configured so operators notice.
+function warnIfNoKey(serviceName) {
+  if (!_getKey()) {
+    log.warn({ action: 'startup_check', service: serviceName, outcome: 'warning', err: 'ENCRYPTION_KEY is not set — tokens will be stored in plaintext. Generate a key with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"' });
+  }
+}
+
+module.exports = { encryptToken, decryptToken, warnIfNoKey };