Pijul × AT Protocol:DID 作为一等公民的身份设计#
动机#
Git 在 Tangled 中的身份是事后关联的:commit 里的 author.email 是自声明字符串,平台通过维护 committerDid 字段来补救,但这个关联只存在于平台数据库,没有密码学保证。
Pijul 提供了重新设计的机会。Pijul change 的 [[authors]] 字段天然是结构化 KV map,可以直接承载 DID。更重要的是,AT Protocol 本身就有完整的签名基础设施——用户的 PDS 会对其 AT repo 的每次写入做 MST 签名,使用的正是 DID 的原生签名密钥。
因此,不需要为 pijul 另造一套密钥体系。DID 即身份,PDS 上的 AT record 即签名证明。
设计原则#
- DID 是唯一身份标识:change 文件中只记录
did,不引入独立的 pijul 签名密钥 - 签名由 PDS 完成:推送时在用户 PDS 上创建
sh.tangled.pijul.refUpdaterecord,PDS 用 DID 的原生密钥对 AT repo 做 MST 签名,这就是归属证明 - change hash 不变:DID 写入 change 的
unhashed部分,不影响 hash 计算,客户端和服务器的 change 保持一致 - 验证依赖 AT Protocol 网络:这与"DID 本身需要联网解析"一致,不是额外代价
签名链#
pijul push(CLI 侧发布):
用户运行 pijul identity link-at → 本地 keyring 存入 AT access token
↓
pijul push → knot server 接收 changes
↓
CLI 从 keyring 取 token,以用户身份向其 PDS 发 createRecord 请求
↓
PDS 将 sh.tangled.pijul.refUpdate 写入用户的 AT repo
↓
PDS 用 DID 原生签名密钥对 MST 做签名
↓
结果:该 DID 密码学地声明了这些 change hash 归属于自己
apply/merge(appview 侧发布):
用户通过 OAuth 登录 appview → appview 持有用户 AT 会话
↓
用户 merge discussion → appview 以用户身份向其 PDS 发 createRecord 请求
↓
(同上)PDS MST 签名完成
与独立 pijul 签名密钥方案的对比:
| 独立 pijul 密钥方案 | DID-only 方案 | |
|---|---|---|
| 签名密钥 | 单独生成的 Ed25519 | DID 原生签名密钥 |
| 信任链 | pijul key → prove → DID(两跳) | DID(直接) |
| 签名位置 | change 文件内 | PDS 的 AT repo MST |
| 需要 prove 流程 | 是 | 否 |
| 服务器可否伪造 | 否(无用户私钥) | 否(需用户持有 AT 凭证) |
DID-only 方案的签名不可伪造:createRecord 请求需用户的有效 AT Protocol 会话(push 时由 CLI 持有,merge 时由 appview 代持),这个会话本身就是用户主动授权的结果。
Change 文件格式#
DID 写入 change 的 hashed [[authors]] 部分,参与 hash 计算:
# change 文件(hashed 部分,决定 change identity)
message = 'Fix bug in parser'
timestamp = '2026-04-03T10:00:00Z'
[[authors]]
did = "did:plc:xxxxxxxxxxxx" # DID 是唯一身份,无 key 字段
# change 文件(unhashed 部分)
# signature = "..." # pijul 私钥对 hash 的签名(现有机制,独立于 DID)
配置了 DID 的 identity 在 record 时只写 did,不写 key。未配置 DID 时保持原有 key 行为,向后兼容非 Tangled 服务器。hash 包含 DID,PDS record 里列出的 change hash 密码学绑定了 DID 与内容。
Lexicon 设计#
sh.tangled.pijul.refUpdate(更新现有)#
现有 lexicon 缺少 committerDid 和正确的 repo 格式,更新如下:
{
"lexicon": 1,
"id": "sh.tangled.pijul.refUpdate",
"defs": {
"main": {
"type": "record",
"description": "Published to the committer's PDS when pijul changes are pushed. The PDS MST signature over this record is the verifiable proof of authorship.",
"key": "tid",
"record": {
"type": "object",
"required": ["repo", "channel", "newState", "changes", "committerDid"],
"properties": {
"repo": {
"type": "string",
"format": "at-uri",
"description": "AT URI of the repository (at://did:plc:owner/sh.tangled.repo/name)"
},
"channel": {
"type": "string",
"description": "Channel that was updated"
},
"oldState": {
"type": "string",
"description": "Pijul Merkle state hash before push (empty for new channels)"
},
"newState": {
"type": "string",
"description": "Pijul Merkle state hash after push"
},
"changes": {
"type": "array",
"items": { "type": "string" },
"description": "Base32 change hashes pushed in this operation, in application order"
},
"committerDid": {
"type": "string",
"format": "did",
"description": "DID of the pusher. Redundant when this record is in the committer's own PDS, but preserved for when it is replicated elsewhere."
},
"languages": {
"type": "object",
"description": "Optional map of language name to line count, computed by knot server"
}
}
}
}
}
}
关键决策:此 record 发布到执行操作者的 PDS,语义是"我(操作者)将这些 change 推送/合并进了这个 repo"。
- 直接推送(
pijul push):操作者 = 推送者,record 发到推送者的 PDS - apply/merge:操作者 = 执行 merge 的人(通常是 repo owner),record 发到他的 PDS
这样 appview 始终持有操作者的 AT Protocol 会话,可以直接调用其 PDS。committerDid 字段是执行操作者的 DID。原始 change 创作者的身份保留在 change 文件的 unhashed.identity.did 中,两者独立。
推送流程#
pijul push(HTTP 或 SSH,CLI 认证)#
Pijul push 的 PDS 发布由 CLI 本地完成,无需服务器中转。用户先通过 pijul identity link-at 将 AT Protocol 凭证与本地 identity 绑定:
pijul identity link-at --pds https://bsky.social
→ 提示输入 handle + app password
→ 认证成功后:DID + at_pds 写入 identity config,access token 存入 keyring
push 流程:
用户: pijul push https://tangled.sh/did:plc:xxx/myrepo
↓
knot server:
1. 接收 change 文件,写入 repo
2. (TODO)将 DID 写入每个 change 的 unhashed.identity.did
(需 Bearer token 认证,knot server 从 token 解析 DID)
CLI(push 成功后):
3. 从 keyring 取 AT access token(link-at 时存入)
4. 解析 remote URL 中的 DID,构造 at:// repo URI
5. 调用 com.atproto.repo.createRecord → 发布到用户 PDS
6. PDS MST 签名完成 → 归属证明建立
注意:remote URL 须使用 DID 格式(https://tangled.sh/did:plc:xxx/repo),不能用 handle,
否则 CLI 无法直接推导 AT URI。未配置 link-at 时 push 照常工作,只是不发布 PDS record。
SSH push 与 HTTP push 共用同一套 CLI 发布逻辑(均经过 Push::run() → remote.finish())。
apply/merge 操作#
merge 由 appview 的 discussions.go 处理(pijul-only)。appview 已持有执行者的 AT 会话,
可直接内联发布,无需 event queue 中转:
Bob merge 了 Alice 的 change:
appview: actorDid = Bob 的 DID(执行操作者)
appview: 用 Bob 的 OAuth 会话调用 com.atproto.repo.createRecord
committerDid = Bob 的 DID,record 发布到 Bob 的 PDS
Bob 的 PDS 记录了他将 Alice 的 change 合并进了这个 repo。Alice 的创作身份保留在 change 文件的 unhashed.identity.did 中。
验证流程#
验证 change X 的推送记录#
1. 查询 knot server 的 changes 历史,得到 committerDid(谁推送/合并了这个 change)
2. 查询 committerDid 的 PDS,列举 sh.tangled.pijul.refUpdate 记录,
找到 changes[] 包含此 change hash 的那条
3. PDS 的 MST 签名(验证方法:AT Protocol 标准 repo 验证流程)
证明:该 DID 声明将此 change hash 推送/合并进了该 repo
验证 change X 的创作身份#
1. 打开 change 文件,读取 unhashed.identity.did(原始作者)
2. 该 DID 是 change 创作者的自声明身份
(无密码学签名,但 change hash 不变,内容完整性有保证)
查询某 DID 的所有推送/合并历史#
GET <pds>/xrpc/com.atproto.repo.listRecords
?collection=sh.tangled.pijul.refUpdate
→ 按 repo 字段分组,得到该 DID 在所有 repo 的操作历史
这是一个去中心化的操作图:不依赖任何单一平台数据库,任何人都可以从 AT Protocol 网络重建完整的推送记录。
与 fedi-xanadu 的集成#
Fedi-xanadu 是本设计的第一个实现者,使用场景略有不同——用户通过 Web 界面编辑文章,服务器代为 record。
流程:
用户通过 Web UI 编辑并保存文章
→ fx-server 的 publish_article_content 调用 pijul.record(node_id, message, Some(&user.did))
→ pijul change 的 unhashed 写入 user.did
→ fx-atproto 在用户 PDS 上发布 sh.tangled.pijul.refUpdate
→ 文章历史页面展示 DID,链接到用户主页
由于 fedi-xanadu 已有用户的 AT Protocol 会话,向其 PDS 写 record 不需要额外的认证步骤。
开放问题#
1. 历史 change 的追溯
对于在本方案实施之前推送的 change,可以允许用户发布一条声明 record 来认领历史 change,由 knot server 验证用户确实有该 repo 的推送记录后接受。
2. [[authors]] 格式与 pijul 上游的协调
did 字段写入 hashed [[authors]] 目前是 Tangled 的约定。应与 pijul 上游协调是否纳入官方格式,或在文档中明确为扩展字段。