Stories

Detail Return Return

👋 一起寫一個基於虛擬模塊的密鑰管理 Rollup 插件吧(一) - Stories Detail

在現代 Web 應用開發中,密鑰的使用幾乎是不可避免的,無論是加解密本地敏感數據、調用第三方 SDK 還是網絡請求籤名等場景都需要用到密鑰。

如何相對安全、靈活地管理密鑰一直是一個令人頭疼的問題,我們既希望在開發環境可以方便地修改、調試和注入密鑰,又不希望這些密鑰在構建產物中被明文暴露,以免被有心之人輕鬆獲取。

通常情況,我們會先手動將密鑰通過特定的算法混淆拆分成多份放入源碼中,運行時再通過逆運算將這些片段合併還原得到密鑰原文,這樣在構建產物中密鑰就不會以明文的形式暴露。

例如下面這樣:

// 假設 chunk1、chunk2、chunk3 是密鑰混淆拆分後的片段
const chunk1 = "abc";
const chunk2 = "123";
const chunk3 = "!@#";

// 運行時通過逆運算合併還原得到密鑰原文
const key = combine(chunk1, chunk2, chunk3);

console.log(key); // iamxiaohe

但是如果需要添加或者修改密鑰,我們就得針對新的密鑰再重複手動混淆拆分這個操作。眾所周知,手動操作既低效又容易出錯,那麼我們能不能編寫一個插件來自動完成這個過程呢?

插件設計

在開始設計之前,我們先整理一下需求,思考這個插件需要幫我們完成什麼工作,簡單梳理如下:

  1. 能夠直接使用明文配置密鑰
  2. 針對配置的密鑰能夠自動混淆拆分
  3. 運行時自動合併還原密鑰
  4. 密鑰支持簡單的導入使用

API 設計

需求整理完成,然後需要再設想一下期望的插件使用方式,這有利於技術選型以及後續的插件實現工作。

我們希望這個插件的使用盡可能貼合開發者的直覺,最理想的使用方式是這樣的:只需要在構建工具的配置中簡單引入插件,傳入一份密鑰配置表,便可以在業務代碼中通過特定的模塊路徑導入密鑰,而無需關心密鑰的具體構建邏輯與混淆拆分細節。

Vite 是一個超快的前端構建工具,現在大多數項目都使用 Vite 構建,所以我們的插件也考慮為 Vite 提供優先支持。通過查閲 Vite 的 插件 API 文檔,可以知道 Vite 內部由 Rollup 驅動,如果插件不涉及 Vite 獨有的 hook(例如開發服務器相關),那麼就可以編寫一個 Rollup 插件同時支持 Vite 和 Rollup 使用。

至此,我們可以初步構思出插件的 API 設計如下(以 Vite 為例):

// vite.config.(js|ts)

import CryptoKey from "rollup-plugin-crypto-key";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    CryptoKey({
      keys: {
        DEMO_KEY1: "iamxiaohe",
        DEMO_KEY2: "ilovexiaohe"
      }
    })
  ]
});
import { DEMO_KEY1, DEMO_KEY2 } from "crypto-key";

console.log(DEMO_KEY1); // iamxiaohe
console.log(DEMO_KEY2); // ilovexiaohe

虛擬模塊

我們設想從 crypto-key 中導入插件配置的密鑰,但是這個 crypto-key 並不是已安裝的模塊或者某個真實的文件,而是通過插件動態生成對應的代碼並從內存中加載使用,通過查閲文檔發現 虛擬模塊 可以滿足這個需求。

虛擬模塊是一種很實用的模式,使你可以對使用 ESM 語法的源文件傳入一些編譯時信息。

export function myPlugin() {
  const virtualModuleId = "virtual:my-module";
  const resolvedVirtualModuleId = "\0" + virtualModuleId;

  return {
    name: "my-plugin",
    resolveId(id) {
      if (id === virtualModuleId) {
        return resolvedVirtualModuleId;
      }
    },
    load(id) {
      if (id === resolvedVirtualModuleId) {
        return `export const msg = "from virtual module";`;
      }
    }
  };
}

這使得可以在 JavaScript 中引入這些模塊:

import { msg } from "virtual:my-module";

console.log(msg);

虛擬模塊在 Vite(以及 Rollup)中都以 virtual: 為前綴,作為面向用户路徑的一種約定。

如果可能的話,插件名應該被用作命名空間,以避免與生態系統中的其他插件發生衝突。舉個例子,vite-plugin-posts 可以要求用户導入一個 virtual:posts 或者 virtual:posts/helpers 虛擬模塊來獲得編譯時信息。

在內部,使用了虛擬模塊的插件在解析時應該將模塊 ID 加上前綴 \0,這一約定來自 Rollup 生態。這避免了其他插件嘗試處理這個 ID(比如 node 解析),而例如 sourcemap 這些核心功能可以利用這一信息來區別虛擬模塊和正常文件。\0 在導入 URL 中不是一個被允許的字符,因此我們需要在導入分析時替換掉它們。一個虛擬 ID 為 \0{id} 在瀏覽器中開發時,最終會被編碼為 /@id/__x00__{id}。這個 id 會被解碼回進入插件處理管線前的樣子,因此這對插件鈎子的代碼是不可見的。

所以,根據約定我們也使用 virtual: 作為導入前綴,修改如下:

import { DEMO_KEY1, DEMO_KEY2 } from "virtual:crypto-key";

模塊劃分

完成了插件的 API 設計,我們還需要思考插件的模塊如何劃分。

現在,回想之前梳理出的插件需求,如果直接把所有邏輯都裝進一個插件模塊裏,很快會發現一些問題:

  • 邏輯耦合嚴重:密鑰的混淆拆分與還原算法和構建工具相關的插件邏輯混雜在一起,不利於測試和維護。
  • 可複用性差:如果將來希望適配其他構建工具而不侷限於 Rollup / Vite,則無法直接複用。

既然如此,不妨把核心算法與構建工具適配的邏輯分開,讓它們互相獨立、各司其職,但又能通過清晰的接口相互協作。

所以我們可以將插件拆分為以下兩個模塊:

  • crypto-splitter

    這是插件的核心,負責密鑰的混淆拆分與還原,它不關心上層的構建工具。

  • rollup-plugin-crypto-key

    作為插件的橋樑,它負責在 Rollup 中調用 crypto-splitter 的能力實現虛擬模塊相關的邏輯。

插件設計其實是插件開發全流程中最困難的部分,我們已經順利完成,可以説是輕舟已過萬重山。

插件實現

有了清晰的設計思路,現在就可以進入到大家最擅長的編碼環節啦!

crypto-splitter

首先需要選擇一種密鑰拆分還原算法,這是插件最核心的部分,也是最複雜的部分,所以我們 一定要全棧自研、從心出發 讓 AI 幫忙寫一個。

那麼目前主流的密鑰拆分還原算法有哪些呢?AI 回答如下:

Shamir 密鑰共享(Shamir's Secret Sharing, SSS)

  • 基於多項式插值(Lagrange 插值)原理。
  • 將密鑰 K 作為一個域上的常數項,構造一個隨機多項式。
  • 每個參與者得到多項式在不同 x 點的值作為份額。
  • 只要 t 個份額,就可以通過 Lagrange 插值還原原始多項式,從而得到密鑰 K。

Blakley 密鑰共享

  • 基於幾何原理:在 t 維空間中,每個份額對應一個超平面。
  • 原始密鑰對應空間中的一個點。
  • 至少 t 個超平面交點才能唯一確定該點。

Asmuth-Bloom 密鑰共享

  • 基於中國剩餘定理(CRT)。
  • 選擇一組互質整數 m_1 < m_2 < ... < m_n。
  • 密鑰通過模運算生成份額,每個份額 s_i = K mod m_i。
  • 至少 t 個份額即可用 CRT 還原 K。

XOR 分割

  • 將密鑰按比特或字節拆分成若干隨機序列。
  • 最後一個份額通過 XOR 得到,使所有份額 XOR 後等於原密鑰。
  • 例如:K = S_1 ^ S_2 ^ ... ^ S_n。

簡單起見,我們選擇最容易的 XOR 分割算法來實現我們的插件核心。

既然是密鑰拆分和還原,那麼接下來就編寫對應的兩個方法,split 用於混淆拆分密鑰,combine 用於合併還原密鑰。

/**
 * 拆分配置項
 */
export interface SplitOptions {
  /** 拆分片段數量,默認為 4 */
  segments?: number;
}

/**
 * 將輸入的密鑰按指定片段數量進行拆分,生成可重組的隨機化片段數組。
 *
 * @param key 密鑰原文
 * @param options 配置項
 * @returns 隨機化片段數組
 */
export function split(key: string, options: SplitOptions = {}): string[] {
  const {
    segments = 4
  } = options;

  // 如果 key 為空,則直接返回空數組
  if (key.length <= 0) {
    return [];
  }

  const chunks: string[] = [];

  // 生成前 segments - 1 個隨機化片段
  for (let i = 0; i < segments - 1; i += 1) {
    chunks.push(
      [...key]
        .map((char) => {
          return String.fromCharCode(
            char.charCodeAt(0) ^ Math.floor(Math.random() * 256)
          );
        })
        .join("")
    );
  }

  // 生成最後一個片段,保證能通過逆運算還原出密鑰原文
  chunks.push(
    [...key]
      .map((char, index) => {
        return String.fromCharCode(
          char.charCodeAt(0) ^ chunks.reduce((acc, it) => {
            return acc ^ it.charCodeAt(index);
          }, 0)
        );
      })
      .join("")
  );

  return chunks;
}

split 方法將輸入的密鑰 key 按照指定 segments 數量拆分成若干加密片段。前 segments - 1 個片段通過隨機數異或生成,第 segments 個片段通過異或前面的所有片段與 key 生成,這樣可以保證用所有片段才能還原出原始的 key

/**
 * 將拆分後的隨機化片段數組重新合併還原成原始密鑰。
 *
 * @param chunks 隨機化片段數組
 * @returns 原始密鑰
 */
export function combine(chunks: string[]): string {
  // 如果沒有片段,則直接返回空字符串
  if (chunks.length <= 0) {
    return "";
  }

  return [...chunks[0]]
    .map((_, index) => {
      return String.fromCharCode(
        // 按位異或所有片段對應字符
        chunks.reduce((acc, it) => {
          return acc ^ it.charCodeAt(index);
        }, 0)
      );
    })
    .join("");
}

combine 方法接收由 split 方法生成的隨機化片段數組,通過逐字符按位異或的方式恢復原始字符串。它要求必須傳入完整的片段數組,否則無法保證恢復結果的正確性。

至此,我們完成了 crypto-splitter 模塊的開發。

rollup-plugin-crypto-key

現在我們開始編寫 Rollup 插件,將 crypto-splitter 的能力接入到 Rollup 中。

import type { Plugin } from "rollup";
import { getCode } from "./code";

export interface Options {
  /**
   * 密鑰映射表,例如 { KEY1: "xxxx", KEY2: "yyyy" }
   */
  keys?: Record<string, string>;
}

// 虛擬模塊標識符,供用户在代碼中導入使用
const VIRTUAL_MODULE_ID = "virtual:crypto-key";
// 內部使用的虛擬模塊標識符(帶 \0 前綴,避免其他插件嘗試處理這個 ID)
const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;

export default function cryptoKey(options: Options = {}): Plugin {
  const {
    keys = {}
  } = options;

  return {
    name: "crypto-key",
    resolveId(source) {
      // 只關心 virtual:crypto-key,其他模塊不處理
      if (source !== VIRTUAL_MODULE_ID) {
        return null;
      }

      // 返回內部標識符用於 load 階段判斷使用
      return RESOLVED_VIRTUAL_MODULE_ID;
    },
    load(id) {
      // 過濾其他模塊
      if (id !== RESOLVED_VIRTUAL_MODULE_ID) {
        return null;
      }

      // 返回密鑰處理代碼以供運行時調用
      return getCode(keys);
    }
  };
}

接下來,我們需要實現 getCode 方法返回密鑰處理代碼。在開始編碼之前,先要知道期望的結果是什麼,稍加思索後可以得出如下代碼:

import { combine } from "crypto-splitter";

const $1 = ["111", "aaa", "!@#"];
const $2 = ["$%^", "bbb", "222"];

export const KEY1 = combine($1);
export const KEY2 = combine($2);

其中 $1$2 是通過 crypto-splittersplit 方法拆分後的隨機化片段數組,然後通過 combine 方法運行時還原為密鑰原文並導出 KEY1KEY2

那麼 getCode 方法需要做的事情就是遍歷 keys 調用 split 方法拆分密鑰,然後根據上述模板生成並返回 JavaScript 代碼。

// code.ts

import { split } from "crypto-splitter";

export function getCode(keys: Record<string, string>): string {
  const values = Object.entries(keys)
    .map(([key, value]) => {
      return {
        key,
        chunks: split(value)
      };
    });

  return `import { combine } from "crypto-splitter";

${
  values
    .map((item, index) => {
      return `const $${index + 1} = ${JSON.stringify(item.chunks)};`;
    })
    .join("\n")
}

${
  values
    .map((item, index) => {
      return `export const ${item.key} = combine($${index + 1});`;
    })
    .join("\n")
}`;
}

到這裏,我們完成了插件的核心實現:用一個獨立的 crypto-splitter 模塊實現了簡單可複用的拆分/合併算法,再通過 rollup-plugin-crypto-key 模塊把這套機制以虛擬模塊的形式接入到 Rollup / Vite 中,最終用户只需在配置中添加密鑰即可在代碼中像導入普通模塊一樣使用它們。

插件使用

既然完成了插件的實現,最後當然是要體驗使用一下自己編寫的插件啦!

// vite.config.(js|ts)

import CryptoKey from "rollup-plugin-crypto-key";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    CryptoKey({
      keys: {
        DEMO_KEY1: "iamxiaohe",
        DEMO_KEY2: "ilovexiaohe"
      }
    })
  ]
});
import { DEMO_KEY1, DEMO_KEY2 } from "virtual:crypto-key";

console.log(DEMO_KEY1); // iamxiaohe
console.log(DEMO_KEY2); // ilovexiaohe

還是以 Vite 為例,我們可以看到插件的使用非常直觀:只需在 Vite 配置中傳入明文密鑰,業務代碼中即可像導入普通模塊一樣獲取密鑰值,而不必關心拆分、混淆或運行時還原的具體實現。這樣既保證了開發時的便捷性,又避免了在構建產物中明文暴露密鑰,極大地降低了開發與安全管理的複雜度。同時,這也驗證了虛擬模塊的設計思路:插件自動生成模塊內容,用户只需關注導入和使用,而不需要額外手動操作密鑰。

🚨 注意

由於瀏覽器環境的特殊性,任何客户端的保護措施都是 “防君子不防小人”,只能增加破解難度,並不能保證絕對的安全!如果需要提高安全性,應該與其他防護措施相結合。

源碼

插件的完整代碼可以在 virtual-crypto-key 倉庫中查看。贈人玫瑰,手留餘香,如果對你有幫助可以給我一個 ⭐️ 鼓勵,這將是我繼續前進的動力,謝謝大家 🙏!

下一步

現在插件已經可以順利使用並且符合預期效果,我們達成了第一個里程碑!

但是細心的同學會發現,我們的插件在 TypeScript 中使用時會報如下錯誤信息:

Cannot find module virtual:crypto-key or its corresponding type declarations.

這是因為我們沒有為 virtual:crypto-key 提供類型定義,所以 TypeScript 的編譯器並不認識這個模塊。這將會導致類型檢查不通過,並且 IDE 也不知道應該如何提示代碼,讓用户的開發體驗大大降低。

作為一個現代的插件,我們當然要將用户的開發體驗放在第一位,所以下一章我們將會一起實現對 TypeScript 的支持!

user avatar Leesz Avatar nihaojob Avatar zaoying Avatar huichangkudelingdai Avatar febobo Avatar yuzhihui Avatar romanticcrystal Avatar joe235 Avatar yunxiao0816 Avatar abc-x Avatar best-doraemon Avatar leoych Avatar
Favorites 64 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.