A vibe coded tangled fork which supports pijul.
at master 257 lines 9.9 kB view raw view rendered
1# Pijul × AT Protocol:DID 作为一等公民的身份设计 2 3## 动机 4 5Git 在 Tangled 中的身份是事后关联的:commit 里的 `author.email` 是自声明字符串,平台通过维护 `committerDid` 字段来补救,但这个关联只存在于平台数据库,没有密码学保证。 6 7Pijul 提供了重新设计的机会。Pijul change 的 `[[authors]]` 字段天然是结构化 KV map,可以直接承载 DID。更重要的是,AT Protocol 本身就有完整的签名基础设施——用户的 PDS 会对其 AT repo 的每次写入做 MST 签名,使用的正是 DID 的原生签名密钥。 8 9因此,**不需要为 pijul 另造一套密钥体系**。DID 即身份,PDS 上的 AT record 即签名证明。 10 11--- 12 13## 设计原则 14 151. **DID 是唯一身份标识**:change 文件中只记录 `did`,不引入独立的 pijul 签名密钥 162. **签名由 PDS 完成**:推送时在用户 PDS 上创建 `sh.tangled.pijul.refUpdate` record,PDS 用 DID 的原生密钥对 AT repo 做 MST 签名,这就是归属证明 173. **change hash 不变**:DID 写入 change 的 `unhashed` 部分,不影响 hash 计算,客户端和服务器的 change 保持一致 184. **验证依赖 AT Protocol 网络**:这与"DID 本身需要联网解析"一致,不是额外代价 19 20--- 21 22## 签名链 23 24**pijul push(CLI 侧发布):** 25 26``` 27用户运行 pijul identity link-at → 本地 keyring 存入 AT access token 28 29pijul push → knot server 接收 changes 30 31CLI 从 keyring 取 token,以用户身份向其 PDS 发 createRecord 请求 32 33PDS 将 sh.tangled.pijul.refUpdate 写入用户的 AT repo 34 35PDS 用 DID 原生签名密钥对 MST 做签名 36 37结果:该 DID 密码学地声明了这些 change hash 归属于自己 38``` 39 40**apply/merge(appview 侧发布):** 41 42``` 43用户通过 OAuth 登录 appview → appview 持有用户 AT 会话 44 45用户 merge discussion → appview 以用户身份向其 PDS 发 createRecord 请求 46 47(同上)PDS MST 签名完成 48``` 49 50与独立 pijul 签名密钥方案的对比: 51 52| | 独立 pijul 密钥方案 | DID-only 方案 | 53|---|---|---| 54| 签名密钥 | 单独生成的 Ed25519 | DID 原生签名密钥 | 55| 信任链 | pijul key → prove → DID(两跳) | DID(直接) | 56| 签名位置 | change 文件内 | PDS 的 AT repo MST | 57| 需要 prove 流程 | 是 | 否 | 58| 服务器可否伪造 | 否(无用户私钥) | 否(需用户持有 AT 凭证) | 59 60DID-only 方案的签名不可伪造:createRecord 请求需用户的有效 AT Protocol 会话(push 时由 CLI 持有,merge 时由 appview 代持),这个会话本身就是用户主动授权的结果。 61 62--- 63 64## Change 文件格式 65 66DID 写入 change 的 **hashed `[[authors]]`** 部分,参与 hash 计算: 67 68```toml 69# change 文件(hashed 部分,决定 change identity) 70message = 'Fix bug in parser' 71timestamp = '2026-04-03T10:00:00Z' 72 73[[authors]] 74did = "did:plc:xxxxxxxxxxxx" # DID 是唯一身份,无 key 字段 75 76# change 文件(unhashed 部分) 77# signature = "..." # pijul 私钥对 hash 的签名(现有机制,独立于 DID) 78``` 79 80配置了 DID 的 identity 在 record 时只写 `did`,不写 `key`。未配置 DID 时保持原有 `key` 行为,向后兼容非 Tangled 服务器。hash 包含 DID,PDS record 里列出的 change hash 密码学绑定了 DID 与内容。 81 82--- 83 84## Lexicon 设计 85 86### `sh.tangled.pijul.refUpdate`(更新现有) 87 88现有 lexicon 缺少 `committerDid` 和正确的 `repo` 格式,更新如下: 89 90```json 91{ 92 "lexicon": 1, 93 "id": "sh.tangled.pijul.refUpdate", 94 "defs": { 95 "main": { 96 "type": "record", 97 "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.", 98 "key": "tid", 99 "record": { 100 "type": "object", 101 "required": ["repo", "channel", "newState", "changes", "committerDid"], 102 "properties": { 103 "repo": { 104 "type": "string", 105 "format": "at-uri", 106 "description": "AT URI of the repository (at://did:plc:owner/sh.tangled.repo/name)" 107 }, 108 "channel": { 109 "type": "string", 110 "description": "Channel that was updated" 111 }, 112 "oldState": { 113 "type": "string", 114 "description": "Pijul Merkle state hash before push (empty for new channels)" 115 }, 116 "newState": { 117 "type": "string", 118 "description": "Pijul Merkle state hash after push" 119 }, 120 "changes": { 121 "type": "array", 122 "items": { "type": "string" }, 123 "description": "Base32 change hashes pushed in this operation, in application order" 124 }, 125 "committerDid": { 126 "type": "string", 127 "format": "did", 128 "description": "DID of the pusher. Redundant when this record is in the committer's own PDS, but preserved for when it is replicated elsewhere." 129 }, 130 "languages": { 131 "type": "object", 132 "description": "Optional map of language name to line count, computed by knot server" 133 } 134 } 135 } 136 } 137 } 138} 139``` 140 141**关键决策**:此 record 发布到**执行操作者**的 PDS,语义是"我(操作者)将这些 change 推送/合并进了这个 repo"。 142 143- 直接推送(`pijul push`):操作者 = 推送者,record 发到推送者的 PDS 144- apply/merge:操作者 = 执行 merge 的人(通常是 repo owner),record 发到他的 PDS 145 146这样 appview 始终持有操作者的 AT Protocol 会话,可以直接调用其 PDS。`committerDid` 字段是执行操作者的 DID。原始 change 创作者的身份保留在 change 文件的 `unhashed.identity.did` 中,两者独立。 147 148--- 149 150## 推送流程 151 152### pijul push(HTTP 或 SSH,CLI 认证) 153 154Pijul push 的 PDS 发布由 **CLI 本地完成**,无需服务器中转。用户先通过 `pijul identity link-at` 将 AT Protocol 凭证与本地 identity 绑定: 155 156``` 157pijul identity link-at --pds https://bsky.social 158→ 提示输入 handle + app password 159→ 认证成功后:DID + at_pds 写入 identity config,access token 存入 keyring 160``` 161 162push 流程: 163 164``` 165用户: pijul push https://tangled.sh/did:plc:xxx/myrepo 166167knot server: 168 1. 接收 change 文件,写入 repo 169 2. (TODO)将 DID 写入每个 change 的 unhashed.identity.did 170 (需 Bearer token 认证,knot server 从 token 解析 DID) 171CLI(push 成功后): 172 3. 从 keyring 取 AT access token(link-at 时存入) 173 4. 解析 remote URL 中的 DID,构造 at:// repo URI 174 5. 调用 com.atproto.repo.createRecord → 发布到用户 PDS 175 6. PDS MST 签名完成 → 归属证明建立 176``` 177 178注意:remote URL 须使用 DID 格式(`https://tangled.sh/did:plc:xxx/repo`),不能用 handle, 179否则 CLI 无法直接推导 AT URI。未配置 `link-at` 时 push 照常工作,只是不发布 PDS record。 180 181SSH push 与 HTTP push 共用同一套 CLI 发布逻辑(均经过 `Push::run()``remote.finish()`)。 182 183### apply/merge 操作 184 185merge 由 appview 的 `discussions.go` 处理(pijul-only)。appview 已持有执行者的 AT 会话, 186可直接内联发布,无需 event queue 中转: 187 188``` 189Bob merge 了 Alice 的 change: 190 appview: actorDid = Bob 的 DID(执行操作者) 191 appview: 用 Bob 的 OAuth 会话调用 com.atproto.repo.createRecord 192 committerDid = Bob 的 DID,record 发布到 Bob 的 PDS 193``` 194 195Bob 的 PDS 记录了他将 Alice 的 change 合并进了这个 repo。Alice 的创作身份保留在 change 文件的 `unhashed.identity.did` 中。 196 197--- 198 199## 验证流程 200 201### 验证 change X 的推送记录 202 203``` 2041. 查询 knot server 的 changes 历史,得到 committerDid(谁推送/合并了这个 change) 2052. 查询 committerDid 的 PDS,列举 sh.tangled.pijul.refUpdate 记录, 206 找到 changes[] 包含此 change hash 的那条 2073. PDS 的 MST 签名(验证方法:AT Protocol 标准 repo 验证流程) 208 证明:该 DID 声明将此 change hash 推送/合并进了该 repo 209``` 210 211### 验证 change X 的创作身份 212 213``` 2141. 打开 change 文件,读取 unhashed.identity.did(原始作者) 2152. 该 DID 是 change 创作者的自声明身份 216 (无密码学签名,但 change hash 不变,内容完整性有保证) 217``` 218 219### 查询某 DID 的所有推送/合并历史 220 221``` 222GET <pds>/xrpc/com.atproto.repo.listRecords 223 ?collection=sh.tangled.pijul.refUpdate 224 225→ 按 repo 字段分组,得到该 DID 在所有 repo 的操作历史 226``` 227 228这是一个**去中心化的操作图**:不依赖任何单一平台数据库,任何人都可以从 AT Protocol 网络重建完整的推送记录。 229 230--- 231 232## 与 fedi-xanadu 的集成 233 234Fedi-xanadu 是本设计的第一个实现者,使用场景略有不同——用户通过 Web 界面编辑文章,服务器代为 record。 235 236流程: 237``` 238用户通过 Web UI 编辑并保存文章 239→ fx-server 的 publish_article_content 调用 pijul.record(node_id, message, Some(&user.did)) 240→ pijul change 的 unhashed 写入 user.did 241→ fx-atproto 在用户 PDS 上发布 sh.tangled.pijul.refUpdate 242→ 文章历史页面展示 DID,链接到用户主页 243``` 244 245由于 fedi-xanadu 已有用户的 AT Protocol 会话,向其 PDS 写 record 不需要额外的认证步骤。 246 247--- 248 249## 开放问题 250 251**1. 历史 change 的追溯** 252 253对于在本方案实施之前推送的 change,可以允许用户发布一条声明 record 来认领历史 change,由 knot server 验证用户确实有该 repo 的推送记录后接受。 254 255**2. `[[authors]]` 格式与 pijul 上游的协调** 256 257`did` 字段写入 hashed `[[authors]]` 目前是 Tangled 的约定。应与 pijul 上游协调是否纳入官方格式,或在文档中明确为扩展字段。