把官方扩展放进自己的仓库,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 给出了完整流程:
- 01
生成 RSA-2048 密钥对,私钥放 DUCKDB_EXTENSION_SIGNING_PK环境变量; - 02
对扩展二进制算哈希; - 03
openssl pkeyutl -sign用私钥签出 256 字节签名,追加到二进制尾部; - 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。
● ● ●
参考来源
- 01
Extension Repositories 官方文档:https://duckdb.org/docs/2.0/extensions/extension_repositories.html - 02
PR #24777 External Extension Repositories:https://github.com/duckdb/duckdb/pull/24777 - 03
Extension Distribution(目录结构与平台):https://duckdb.org/docs/current/extensions/extension_distribution.html - 04
extension-template 签名脚本:https://github.com/duckdb/extension-template/blob/main/scripts/extension-upload.sh