alitrack

把官方扩展放进自己的仓库,DuckDB 拒绝安装

DuckDB 2.0 里藏了一个大部分用户还没注意到的能力: CREATE EXTENSION REPOSITORY 。一条 SQL,你就能注册自己的扩展仓库,用自己生成的密钥签名,团队成员 INSTALL 安装时的体验和装官方扩展完全一样。 我昨天在本机把这个机制完整跑了一遍,撞上了一个反直觉的结果:我把一个 官方签名的 icu 扩展二进制 原封不动复制进自己的仓库,然后从这个仓库安装——DuckDB 拒绝了,报错是"doesn't have a valid signature"。 官方亲签的文件,在自己仓库里反而不被信任。这不是 bug,是这个设计里最值得讲的部分。 ● ● ●

一条 SQL 建仓库

先说怎么建。整个流程比我预想的短,服务端只需要摆两个东西:
extensions.example.com/
├── .well-known/duckdb-extension-repo.json
└── v2.0.0/
    └── linux_amd64/
        └── 你的扩展.duckdb_extension.gz
.well-known/duckdb-extension-repo.json 是仓库的元数据文件,内容是一个 JSON, signature_keys 数组里放你的 RSA-2048 公钥。下面那层按「DuckDB 版本/平台」组织扩展二进制,和官方仓库的结构一致。prefix 指向哪都行——本地目录、HTTPS、S3 都支持,我在 WSL 上用本地目录就跑通了。 客户端三步:
SET allow_extension_repositories = 'allowed';
CREATE EXTENSION REPOSITORY myrepo WITH PREFIX '/tmp/duckrepo';
INSTALL AND LOAD my_ext FROM myrepo;
第一步是开关,默认值是 undecided (禁止添加新仓库)。第二步执行时,DuckDB 会去抓 .well-known 里的公钥,把它 钉扎 进仓库定义,并打印密钥的 SHA-256 指纹。这个指纹要和你在文档、内部 wiki 或签名公告里带外公布的值人工比对——因为公钥是走网络抓来的,跟扩展走同一条通道,指纹比对就是防中间人的那道闸。不想信任网络的话,可以在 CREATE 语句里用 USING PUBLIC KEY 直接内联公钥,跳过抓取。 第三步的 INSTALL AND LOAD 是 2.0 的新语法,装和载合成一条。 ● ● ●

为什么官方扩展会被拒

回到开头那个实验。我做的操作是:从本机扩展目录里复制一个由 DuckDB 官方密钥签名的 icu 二进制,放进自建仓库的对应目录,然后 INSTALL icu FROM myrepo 。 结果:
IO Error: Attempting to install an extension file that
doesn't have a valid signature
原因在于钉扎逻辑:自建仓库安装的扩展,只验证 这个仓库自己公钥 的签名。官方密钥签的文件在官方仓库里有效,搬到你仓库里就成了「无有效签名」。仓库之间签名不互通,各管各的。 这比 DuckDB 1.x 更严格。旧版本里,每个扩展验证的是 core 和 community 两套密钥的并集;2.0 的实现(PR #24777)把签名密钥按来源隔离——core 扩展只认 core 密钥,community 只认 community 密钥,自建仓库只认自己钉扎的密钥。扩展装完后,实际验证通过的那把密钥的指纹会记录在安装元数据里,通过 duckdb_extensions() 的 signature_key_fingerprint 列可以随时查。 这个设计防的事很具体:有人把一个来历不明的二进制塞进你信任的通道,无论这个二进制在别处有多权威的签名,你的仓库只认你的章。 ● ● ●

单向棘轮:运行期永远无法扩大信任面

注册仓库等于往系统里添加信任的签名密钥,这个动作的危险级别和打开 allow_unsigned_extensions 相当。所以 DuckDB 给它上了同一套锁,还加了一层。 allow_extension_repositories 是三态开关:
状态 行为
undecided (默认) 禁止添加新仓库,已有仓库的扩展仍可安装加载
allowed 可以添加自定义仓库
forbidden 已有仓库也不信任,其扩展验证一律失败
关键在方向性。我实测:运行期把设置从 allowed 改回 undecided ,直接报错——数据库运行期间,这个开关只能收紧,不能放宽。想开 allowed ,必须在启动时通过客户端配置传入。也就是说,任何一条运行中的 SQL 连接,无论权限多高,都无法注册一个攻击者控制的密钥。 另外两道锁:仓库定义存在 ~/.duckdb/extension_repositories 目录(每仓一个 JSON 文件,含钉扎的公钥,重启后钉扎依然有效);在签名校验开启的前提下,这个目录路径本身也只能启动期设置——否则运行期连接可以把它指向一个塞满假仓库文件的目录,绕过整个 opt-in 机制。CREATE 和 DROP REPOSITORY 还会额外受 lock_configuration 约束,可以一键冻结整个信任库。 ● ● ●

加载时的信任断言

还有一个细节我比较欣赏:裸 LOAD my_ext 找不到自建仓库的扩展——必须写 LOAD my_ext FROM myrepo 。 自建仓库的扩展安装在独立的子目录里( repositories/myrepo/ ),两个仓库里有同名扩展也不会撞。裸 LOAD 只解析 core 和 community 的扩展,所以永远不可能「不小心」加载到一个来自用户仓库的文件。而 LOAD foo FROM core 这种写法本身是断言:如果 foo 实际装自别处,直接报错。来源写在语句里,审计的人一眼能看出每个扩展信任的是谁。 ● ● ●

签名怎么做

官方 extension-template 仓库里的 extension-upload.sh 给出了完整流程:
  1. 01 生成 RSA-2048 密钥对,私钥放 DUCKDB_EXTENSION_SIGNING_PK 环境变量;
  2. 02 对扩展二进制算哈希;
  3. 03 openssl pkeyutl -sign 用私钥签出 256 字节签名,追加到二进制尾部;
  4. 04 gzip 压缩,放到仓库目录。
客户端安装时会先找 .gz 版本再找裸文件。不签名也能发,尾部补 256 个零字节凑格式——但那样用户就得开 allow_unsigned_extensions ,回到了老路。 密钥轮换有官方姿势:仓库元数据里可以放多把公钥, CREATE ... USING PUBLIC KEYS 'k1', 'k2' ,全部钉扎、全部信任。换钥匙时新钥旧钥并行一段时间,装过的客户端不会断供,之后再摘掉旧钥。 ● ● ●

谁需要这个

DuckDB 现有的分发通道只有两条:官方 core 仓库,和提交 PR、过 CI、等维护者审核的 community 仓库。对个人开发者够用,但有一类需求一直没着落—— 企业内部的私有扩展 :内部数据连接器、定制算法、还没到开源程度的原型。以前只能 -unsigned 加载裸二进制,等于放弃签名校验;现在可以自建仓库、自管密钥,安全模型和官方通道对齐。 日志类、合规类场景还有个加分项:本地目录就能当仓库。内网机器不联网,U 盘拷一个目录过去、 CREATE EXTENSION REPOSITORY 指向本地路径,签名验证照常工作。 两个注意点。一是语法官方标注 work-in-progress,可能还会调整,落地前盯着 PR #24777 的后续;二是 macOS Intel(osx_amd64)平台正在淘汰,2.0 的 community 扩展已停建该平台,自建仓库如果面向 macOS 用户,只需要管 Apple Silicon。 ● ● ●

参考来源

  1. 01 Extension Repositories 官方文档:https://duckdb.org/docs/2.0/extensions/extension_repositories.html
  2. 02 PR #24777 External Extension Repositories:https://github.com/duckdb/duckdb/pull/24777
  3. 03 Extension Distribution(目录结构与平台):https://duckdb.org/docs/current/extensions/extension_distribution.html
  4. 04 extension-template 签名脚本:https://github.com/duckdb/extension-template/blob/main/scripts/extension-upload.sh