For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/stage/1/proposal-module-keys.md.
  • 简体中文
  • Module Keys S1

    中文标题:模块密钥

    提案概览
    提案速览

    该提案旨在通过增加每模块 API(publicKey、privateKey、box、unbox)实现 ES 模块之间的安全通信通道,使模块能够向其他模块授予不同级别的信任。它基于 Node.该提案侧重于契约值、不透明值、权限和访问限制等用例,但不以安全加载恶意代码为目标。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    TC39 模块密钥(第一阶段提案)

    让项目团队信任他们了解的代码,而不是不信任的代码。

    本提案在 ModuleBody 内增加了每个模块可见的 API,这些 API 支持模块之间的安全通信通道,使大型应用能够向不同模块授予不同程度的信任。

    代码快速链接

    五月 TC39 会议幻灯片

    NPM 上的实现和 polyfilling babel 插件

    它基于 Node.js require() 模块中的概念验证。 它主要是将 [Node frenemies 设计][] 重写为 ES6 模块上下文。

    背景

    大型 EcmaScript 项目是不同作者之间的合作:

    • 一方作者,他们了解项目的目标和安全需求。
    • 直接依赖作者——那些被一方作者显式 import 并且熟悉需要他们的项目类型的安全需求的人。
    • 深层依赖,由试图解决一般问题但对任何特定项目的具体安全需求知之甚少的人编写。 当您必须经过多层 import 才能找出它为何被加载时,该依赖就是“深层”的。

    目标

    允许项目作者向他们熟悉的模块授予更多权限,同时仍然向深层依赖授予一些权限。

    我们希望确保深层依赖在获得较少权限时,最不费力的路径是优雅地降级,而不是绕过安全措施以提供更高质量的服务,但伴随更高的风险。

    非目标

    允许安全地加载恶意第三方代码不是目标。 即使是出于善意编写的代码,如果部分代码编写粗心,或者其作者对“安全”的定义与一方作者不同,也可能使项目面临风险。

    我们假设所有模块作者都是出于善意,但一方和第三方作者最终仍然会以相互矛盾的目标工作,并且一方作者更适合代表最终用户的利益。

    API 草图

    下面我们使用名称 frenemies。这是一个糟糕的名字。如果这进入第三阶段,我们希望能集思广益出一个更好的名字。

    对于每个 ModuleBody,我们提供一个冻结的 API:

    • frenemies.publicKey():一个函数,当调用栈上有私钥且调用栈上最浅的私钥对应于这个密钥时返回 true,否则返回 false。
    • frenemies.privateKey(f):一个函数,调用 f 并返回其结果。参见 .publicKey 的调用栈关系。
    • frenemies.box(value, mayOpen):返回一个唯一的 Box 实例。
    • frenemies.unbox(box, ifFrom, fallback):等待 box。 设 valuemayOpen 为创建 box 时提供的参数,boxer 为用于创建盒子的 Frenemies 实例。 如果在 frenemies.privateKey 的上下文中调用 mayOpen(frenemies.publicKey) 返回 true,并且boxer.privateKey 的上下文中调用 ifFrom(boxer.publicKey) 返回 true,则对 unbox 的调用返回 value。 否则,返回 fallback

    在模块体执行开始之前,我们 export frenemies.publicKey as publicKey。 像默认导出一样,publicKey 不参与再导出; 它被包含在 * 中以用于 export * from ...

    对于每个 realm,我们提供:

    • 一个 makeFrenemies 函数,产生一个唯一的 frenemies 实例。 这将有助于源代码重写器保持模块语义。见下文。
    • 一个 class Box { toString() { return '[Box]' } }
    • 一个 isPublicKey(x) 函数,仅对由 makeFrenemies 产生的公钥返回 true。这使编写可靠的 mayOpenifFrom 谓词更容易。

    用例总结

    这些用例在后面的方案中会更详细地讨论。

    大多数用例围绕将已知与项目安全目标一致的模块与行为需要限定的深层依赖群体区分开来。

    • 在服务器端,使用 JS 引擎钩子限制 new Function 的使用,仅允许经过仔细审查的模块中的有意使用。
    • 防止或警告未经白名单的模块使用强大的 API(那些发送消息、调用 shell 的 API)。
    • 限制使用不安全或易出错且需要特定专业知识和谨慎使用的 API。错误消息可以引导用户使用适合一般用途的包装 API。

    这还可以实现构造即安全保证:

    • 允许某些模块铸造通过验证器的值,以便任何模块都可以验证它们是构造即安全的,无论它们经过哪些模块。这对于封装安全属性的 契约值 特别有用。 例如,[可信类型][] 受益于能够将创建通过验证器的值的能力限制在已经过领域专家审查的模块。

    不透明值使敏感数据的保密更容易:

    • 允许一个提取敏感输入的层将它们传送到目的地,而不必担心中间模块。 例如,用于重置密码或信用卡处理表单的代码可能希望确保两者都不会出现在日志中。能够包装一个值可以保证敏感输入不会出现在日志或错误跟踪中。
    • 类似地,对于 PII,如来自客户端地理 API 的实际位置。

    解决方案:Node 模块加载器作为可信中介以启用相互怀疑

    如果我们有一种方式让一个模块向另一个模块打开可靠通道,我们就可以实现这些用例。

    可靠通道示例

    两个模块 alice.jsbob.js 希望以紧密耦合的方式协作(对彼此的请求不那么怀疑),同时更仔细地检查来自其他模块的输入。他们可能希望使用 carol.js 传递消息,但不想授予 carol.js 读取权限。

    实现这一目标的一种方式是 Alice 创建一个包含她希望传达的值的盒子,该盒子只有 Bob 能打开(机密性),并为 Bob 提供一种知道该盒子来自 Alice 的方式(真实性)。

    如果我们只想传递字符串并且 Alice 和 Bob 有交换密钥的方式,我们可以通过非对称加密来实现,但闭包局部变量的 JavaScript 对象不能很好地序列化。

    [随机预言][] 模型从输入到不可预测字符串的稳定映射来解释密码学原语。 JavaScript 不允许伪造对象引用(例如将 int 强制转换为指针)或从闭包的局部作用域提取对象引用。 这两个属性,即 new Object 的唯一性和私密性,使我们能够在不序列化的情况下获得 JavaScript 对象的密码学风格操作符的好处,只需将该模型替换为 不同对象引用预言 模型。

    frenemies.js 中,我们实现了安全通道的纯 JavaScript 模拟,可以传达任意值,提供 机密性和真实性,并建立在 JavaScript 模拟的私钥/公钥对之上。

    它不提供 完整性不可否认性。如果盒装值通过其他方式可用,它可能在装箱和拆箱之间被修改。因此,尽管打包者不能否认盒装值的身份,但他们可以否认对象内任何可通过未在装箱时冻结/密封的属性访问的属性的值(或缺失)。要获得不可否认性,我们需要提供一个拆箱操作符,在装箱完成之前额外检查对象是否深度冻结和密封。

    可用性 在此上下文中没有明确含义。在上面的例子中,不保证 Carol 会将 Alice 的盒子传递给 Bob;内存不足错误、栈溢出或操作系统中断可能阻止过程中的任何步骤。

    私钥 是一个每模块函数,接受一个函数。它调用其参数,使得相应公钥的调用返回其第一个参数而不是第二个。

    公钥 是一个每模块函数,当在相应私钥的上下文中调用时返回其第一个参数(默认为 true),否则返回其第二个参数(默认为 false)。

    示例代码

    // alice.js
    'use strict';
    // Alice 发送一条消息给 Bob。
    
    import * as bob from './bob.js';
    import * as carol from './carol.js';
    
    const mayOpen = (opener) => opener === bob.publicKey && opener();
    
    export function send () {
      const messageForBob = frenemies.box(
        'Have a nice day, Bob! Sincerely, Alice',
        mayOpen);
    
      console.group('Alice is sending');
      carol.convey(bob, messageForBob);
      console.groupEnd();
    }
    // bob.js
    'use strict';
    // Bob 从 Alice 那里收到消息并验证来自她。
    import * as alice from './alice.js';
    
    function ifFrom(sender) {
      return sender === alice.publicKey && sender();
    }
    
    // Carol 把消息放入邮箱。
    export function mailbox(box) {
      const value = frenemies.unbox(
        box, ifFrom, 'a message of questionable provenance!');
      console.log(`Bob read: ${value}`);
    }
    // carol.js
    'use strict';
    // Carol 在 Alice 和 Bob 之间传递消息。
    // 也许她是一个消息总线。谁知道呢?!
    
    // 也许 Carol 是邪恶的!也许不是!再说一次,谁知道呢?!
    const evil = Math.random() >= 0.5;
    
    export function convey(recipient, message) {
      if (evil) {
        console.log('Carol got ' + message);  // OPAQUE. No leak.
        // 没有泄漏。由于 alice.mayOpen 在 Alice 的私钥上下文中被调用,而不是 Bob 的。
        console.log('Carol unboxed ' + frenemies.unbox(message, (x) => true, 'Fallback value'));
      }
      // Carol 投递 Bob 的邮件。她可能很邪恶,但她不是怪物!
      recipient.mailbox(message);
      if (evil) {
        recipient.mailbox(
          // Bob 不会打开它,因为他的 ifFrom 谓词期望 Alice 的公钥,而不是 Carol 的。
          frenemies.box('Have an evil day! Sincerely, Alice', (x) => true));
      }
    }

    用例解决方案草图

    这里我们勾勒出我们如何解决上述用例。

    契约值

    项目成员可能基于对某些模块的广泛经验信任它们生成 HTML:

    • widely-used-html-sanitizer.js 过滤 HTML 标签和属性
    • autoescaping-template-engine.js 将不可信值插入可信 HTML 模板

    但不信任其他模块:

    • mysterious-markdown-converter.js 由于一些无人调查的原因出现在项目依赖中。

    幸运的是,所有这些模块产生 TrustedHtml 值,它包含一个包含 HTML 字符串的盒子。

    当需要将一块 HTML 刷新到 HTTP 响应缓冲区时,请求处理器会打开 TrustedHTML 内容,如果它来自前两个模块。如果来自其他来源,它会以更多的怀疑态度对待它,也许将其传回清理器,后者对其来源不加考虑地拆箱,并白名单标签和属性。

    模板引擎也是消费者——在渲染时,它获取项目策略来决定何时内联一块可信 HTML 而不转义。

    其他项目团队可能以他们自己的策略信任 widely-used-html-sanitizer.js,但不信任第三方代码制定自己的策略,因此只白名单 widely-used-html-sanitizer.js 的包装版本。

    本提案提供每模块的 publicKey,为定义白名单提供了基础,并为值的消费者提供了检查白名单的机制。

    盒子在传输过程中防篡改,因此只要中间层不坚持将值强制转换为字符串,就不需要重新组织数据流经系统的方式。

    库代码可以定义公共密钥谓词:

    /**
     * 检查密钥是否被允许的公钥谓词。
     */
    export function makeWhitelist(...allowed) {
      const idSet = new Set(allowed)
      return (publicKey) => (
          frenemies.isPublicKey(publicKey) &&
          publicKey() &&
          // TODO: 避免依赖 Set.prototype
          idSet.has(publicKey))
    }

    并且配置可以创建白名单:

    import {publicKey as fooKey} from 'foo';
    import {publicKey as barKey} from 'bar';
    import {makeWhitelist} from '/whitelists.js';
    
    const myWhitelist = Object.freeze(makeWhitelist([fooKey, barKey]);

    不透明值

    当一切正常时,我们可以信任我们的框架代码将输入路由到正确的位置,但有太多代码看起来像

    function execute(...args) {
      try {
        // ...
      } catch (exc) {
        log('Execution failed given %o: %o', args, exc);
        // ...
      }
    }

    dataBundle 可能包含敏感信息:

    • 真实姓名
    • 未加盐的密码
    • 地理位置
    • 信用卡

    我们需要鼓励开发人员构建可以在现场调试的系统,但仍然需要将敏感数据排除在可能不像我们的密钥存储那样抵御攻击的日志之外。

    可靠的不透明值可以帮助我们平衡这两个需求。

    一旦请求处理器提取敏感数据,它就可以将其装箱,并使用上述相同类型的模块白名单指定哪些模块可以打开它。

    不透明值不需要相互怀疑,因此可以通过其他方式获得相当可靠的不透明值。

    具体化权限

    那么 eval 解释了 JavaScript 的特性使其比其他语言更容易无意中将字符串转换为代码,而 动态限制 eval 提出了一种方法,允许某些 eval 的使用,而不必实施 --disallow_code_generation_from_strings 所隐含的全面禁止。

    我们可以将权限表示为盒子,权限检查器可以使用一个 ifFrom 参数拆箱它,该参数只接受权限授予者。

    为了请求权限,请求者会创建一个盒子并交给授予者,授予者可以使用上述白名单方案。

    这些权限可以委托,但有常见的注意事项。

    权限也可以撤销。

    // 授予者
    const whitelist = (见上文);
    function mayI(request) {
      let permission () => false
      let revoke = () => {}
      if (frenemies.unbox(request, whitelist, false)) {
        let youMay = true;
        permission = () => youMay
        revoke = () => { youMay = false }
      }
      return {
        permission: frenemies.box(permission, () => true),
        revoke
      }
    }
    
    
    // 请求者
    import * as granter from '/granter.js';
    const { permission, revoke } = granter.mayI(frenemies.box(true, () => true));
    // 做一些使用 permission 调用检查器的事情
    
    
    // 检查器
    import * as granter from '/granter.js';
    function check(permission) {
      if (frenemies.unbox(
              permission, (k) => k === granter.publicKey && k(), () => false)()) {
        // 允许
      } else {
        // 拒绝
      }
    }

    访问限制

    有些模块天生敏感。例如,在 Node.js 上下文中:

    • fs——文件系统访问
    • child_process——shell 访问
    • net——网络发送
    • 包装这些的用户库

    在客户端,未来的敏感 API 可能作为内置模块暴露。

    这些功能极其强大,大多数大型项目都使用其中一个或全部,效果显著。

    它们或其他语言中的对应物也参与了许多大规模漏洞。

    大多数第三方模块并不需要所有这些功能。

    项目团队能够限制哪些代码应该使用这些功能,审查这些代码,然后强制执行,这将是一件好事。然后,如果新依赖需要访问,它应该大声失败,以便他们可以增量审查新的使用。

    巧妙使用反射操作符(例如我们提倡权限的 Function 构造函数)如果安全失败,那也很好。

    我们已经展示,本提案为模块白名单提供了基础,这让我们能够定义已审查的边界。

    如果我们能依靠敏感模块选择 export if (importerPublicKey => ...);,我们可以使用额外语法来强制执行这一点。

    [解析钩子][resolve hooks] 将允许基于考虑到“父”和“子”模块(由其密钥标识)的检查来否决任意导入。

    备选方案

    以下是为不同代码授予不同权限而提出的备选方案。

    我们都是成年人了。

    大多数 JavaScript 项目在开发中采取“我们都是成年人”的态度。 我们都不会做出像 undefined = 1337 这样的蠢事,所以我们可以和睦相处,不用担心只有在 undefined === 1337 时才会出现的边缘情况。

    这个论点以两种不同的方式提出:

    • 我们都是成年人,所以我们可以向第三方库作者提供他们可能需要的所有权限,并信任他们代表客户管理风险。
    • 我们都是成年人,所以如果我们确实需要在代码中实现安全策略,我们可以使用一些过渡措施,因为成年人不会试图绕过安全策略。

    这种态度适可而止是好的,但我们并不都是成年人——我们是一大群成年人,不可能都互相交谈,因为那是一个 O(n²) 的问题。

    大群体没有“我们都是成年人”所暗示的共同背景。

    有几种背景影响最终产品的安全性。

    • 深层依赖的作者不理解最终产品的具体安全需求。
    • 深层依赖的作者通常是自己库解决的问题的领域专家,但通常不是攻击者如何将可能使用的强大功能对付最终产品的专家。
    • 最终产品的开发人员不了解深层依赖作者如何解决问题。

    如果第三方开发者在是否使用强大功能来肯定地解决一个向其提出请求的客户的问题,以及为其他未在场且未要求不使用该功能的客户降低风险之间做出选择,他们很可能会这样做。那些一直不这样做的人不会获得用户。

    期望第三方开发人员为特定最终产品近似 POLA 是不合理的。

    我们需要让一小群安全专家能够保证安全属性,同时第三方开发人员专注于发挥他们的优势。

    关于我们是否可以信任成年人不会绕过过渡措施,在上面的目标中我们想要

    确保深层依赖最不费力的路径是优雅地降级

    库作者希望提供高水平的服务。 如果他们可以通过窥视盒子内部这样做,并且没有清楚地看到这如何增加最终产品的风险,那么他们可能会窥视。 如果他们无法窥视内部,最不费力的路径就是优雅地降级。

    我们都是成年人;有时是有最后期限且咖啡不够的成年人。牢固的围栏使邻里和睦,让安全专家管理产品安全,并引导第三方开发人员走向优雅降级,远离绕过政策的黑客行为。

    单元测试

    为什么不编写单元测试来确保你的代码不对不可信输入做不应该做的事情呢?

    单元测试套件可以让你确信系统做了它应该做的事情,但在检查它不做不应该做的事情方面做得很差。限制或遏制代码在暴露于恶意输入时可能造成的损害的机制可以帮助大型开发团队保持速度,同时给予对后者的信心。

    关闭不需要的功能

    这主要在 Node.js 的背景下提出,但也以 Feature-Policy 的形式提出。

    我们讨论像 fssh 这样的强大模块和像 eval 这样的强大操作符,并注意到很少有模块需要这些,但大多数项目需要。如果不是这样,shell 注入和敏感文件泄漏就不会那么频繁。

    如果我们能限制负面影响,项目团队在需要时拥有它们是有益的。

    检查所有第三方依赖

    有人认为开发人员不应该依赖任何他们没有读过的代码,或者他们不会乐意编写的代码。

    开发人员确实使用他们没有读过的代码。

    我们不提倡阅读所有依赖,因为这听起来非常无聊,并且可能会成为你的全职工作。

    自己编写而不是使用第三方代码

    对于项目关键的部分,如果项目团队有专业知识,这可能是一个好方法,但不可扩展。

    如果一个大项目尝试这样做,他们将不得不变得足够大,内部有足够的压力提供可重用组件,然后他们会重现已方/第三方脱节的问题。

    在独立上下文中加载模块

    有人提议在每个模块自己的 realm 中加载模块,并带有自己的全局变量版本。

    这可能是一个好主意,但可能会破坏假设 Object.prototype 和其他内置对象在模块之间相同的模块。

    它本身也不解决这些用例,尽管如果有一种方法阻止某些模块 require 特定的其他模块,它可能会解决。

    它可能与本提案互补。

    对代码重写器的影响。

    合并或捆绑模块的代码重写器将不得不改变以适应本提案,为提到 frenemies 的模块制造公钥/私钥对。

    即使未提到 frenemies,重写器也会添加一个隐式的公钥导出。如果一个模块从不提到 frenemies,那么它从不使用其私钥,因此附加一个众所周知的公钥(其对应的私钥已被丢弃)就足够了。这个众所周知的公钥将等价于 (a, b = false) => b 但会通过 isPublicKey 谓词。 消除死代码的代码重写器应该能够在许多情况下消除这个痕迹。

    模块加载器钩子

    我们假设模块加载器钩子是可信的,因此不防御那些收集私钥的模块钩子。已发布的加载器钩子作者指南中的任何“安全注意事项”部分都应提到加载器钩子作者有责任不泄漏私钥。

    故障模式

    “向模块授权”隐含的想法是模块是主体。这带来了根据主体授权时通常的风险:

    冒充 - 一个模块冒充另一个模块。 例如,vm 模块使用 filename 选项以在堆栈跟踪中明显看出代码来源的方式加载代码。 使用堆栈内省(例如 node-sec-patterns)来验证调用者的模块可能会错误地授权在 { filename: './high-authority-module.js' } 创建的上下文中运行的代码。

    模块可能试图向其调用的敏感函数证明其身份的另一种方式是传入一个只有它知道的值。这容易受到重放攻击——一旦传递,接收方就知道了该值,并且可以保存该值,以便以后使用调用者的权限。

    只要模块不泄漏其 privateKeyboxunbox 函数,所附代码就不应容易受到冒充。

    如果模块身份基于字符串名称,那么在单独的 realm 中加载相同的模块(例如通过 <iframe>)可能会允许绕过。本提案中的所有可变状态都是每个 realm 的,因此本提案不应遭受此攻击向量。

    攻击策略 - 对于服务器端代码,如果我们将授权决策存储在像 package.json 这样的配置文件中,权限较低的模块可能(暂时或永久)编辑该文件以授予自己更多权限。

    克隆攻击 - 对于服务器端代码,权限较低的模块可以在权限较高的模块的主文件前面添加它希望运行的脚本。

    我们不尝试解决最后这两个问题。现有技术如拒绝节点运行时对源代码和配置文件的写访问以及加载时进行资源完整性检查应该足够了。

    攻击对象 - 对于服务器端代码,Node.js 不运行 JS。C++ 插件可能能够违反我们 随机预言替代 所基于的假设。程序化访问带外调试器(1 2)和 [反序列化 API][] 也在类似系统中允许了对象伪造。

    我们也不尝试解决这些问题。“如果你不能信任原生代码,你还能信任谁”并不是理想的安全姿态,但项目团队在加载哪些 C++ 插件到生产环境时应该已经小心,而这样的功能可能允许限制对带外 API(如调试钩子和反序列化 API)的访问,这比没有这样的限制是更好的安全情况。(调试 API 可能应该在生产环境中关闭。)