git-bug 身份(Identity)数据格式规范:从版本链、Lamport 时钟到密钥轮换的完整实现解析
【免费下载链接】git-bugDistributed, offline-first bug tracker embedded in git项目地址: https://gitcode.com/GitHub_Trending/gi/git-bug
导读
本文是 git-bug 分布式离线优先缺陷追踪器中**身份数据格式(Identity Format)**的正式规范与源码级解析。身份代表"操作作者"(即用户),它们独立于 bug 等实体存储,仅通过 ID 被 operation pack 引用。读完本文,你将掌握 git-bug 身份如何以 git commit 线性链形式存储、version blob 的 JSON 字段语义、SHA-256 身份 ID 的派生规则、仅允许 fast-forward 的合并策略,以及面向未来签名验证的密钥查找算法。文末附有 测试向量 与对应的 Go 实现路径,可帮助读者直接对照验证。
1. 概览:身份是一种"随时间变化"的可变记录
git-bug 把身份建模为用户资料随时间的可变记录,其形式化定义如下:
- 一个身份是用户 profile(姓名、联系方式、密码学公钥)在时间轴上的线性版本序列(linear sequence ofversions);
- 每个版本都是某个时间点上用户信息的完整快照;
- 版本一旦写入即不可变(immutable),更新身份的唯一方式是追加一个新版本;
- 身份的当前状态由最后一个版本决定(见 identity.go 中
Name()、Email()、Login()、Keys()等均读取lastVersion())。
需要特别强调的是:身份格式与通用的 DAG 实体格式(见 dag-entity.md)完全不同:
| 维度 | DAG 实体格式 | 身份格式 |
|---|---|---|
| 链结构 | DAG(有向无环图),支持并发合并 | 简单线性 commit 链,每 commit 至多一个父节点 |
| 数据载体 | OperationPack(操作包) | 单 blob(version) |
| 变更语义 | 追加操作(operation) | 追加版本(version) |
| 合并 | 支持并发场景的确定性合并 | 仅 fast-forward,冲突直接失败 |
当前格式版本:2(源码常量定义见 version.go)。
2. Git 引用(Reference)布局
身份通过以下两种 git reference 暴露:
| Reference 模式 | 含义 |
|---|---|
refs/identities/<identity-id> | 本地身份 |
refs/remotes/<remote>/identities/<identity-id> | 远端跟踪身份(remote-tracking identity) |
其中<identity-id>是身份的 64 位小写十六进制 SHA-256 ID(见 §5 身份 ID 派生)。
源码中的常量(identity.go)与此表一一对应:
const identityRefPattern = "refs/identities/" const identityRemoteRefPattern = "refs/remotes/%s/identities/" const versionEntryName = "version"本地身份的读写入口为ReadLocal(repo, id)(拼装refs/identities/+ ID)与ReadRemote(repo, remote, id)(拼装远端模式),见 identity.go。ListLocalIds则通过repo.ListRefs(identityRefPattern)枚举全部本地身份 ID。
3. Commit 与 Tree 结构:强制"恰好一个 version 条目"
身份线性链上的每个 commit都指向一个 git tree,该 tree必须恰好包含一个条目:
| Tree 条目名 | 对象类型 | 说明 |
|---|---|---|
version | blob | JSON 序列化的身份版本(见 §4 Version Blob) |
读取方(reader)必须拒绝以下两种情况:
- tree 条目数 ≠ 1;
- 唯一条目不叫
version。
源码中的读取校验逻辑(identity.go)逐条实现了该强制规则:
entries, err := repo.ReadTree(hash) ... if len(entries) != 1 { return nil, fmt.Errorf("invalid identity data at hash %s", hash) } entry := entries[0] if entry.Name != versionEntryName { return nil, fmt.Errorf("invalid identity data at hash %s", hash) } data, err := repo.ReadData(entry.Hash) // 读取 version blob ... json.NewDecoder(data).Decode(&version) // 解码 JSON 版本写入侧同样严格:Commit()为每个未提交版本创建一个只含version条目的 tree(identity.go):
tree := []repository.TreeEntry{ {ObjectType: repository.Blob, Hash: blobHash, Name: versionEntryName}, } treeHash, err := repo.StoreTree(tree)链是线性的:每个 commit 至多一个父节点(repo.StoreCommit(treeHash, lastCommit)把上一个版本的 commit 作为父节点,首个版本则无父节点)。两个无法 fast-forward 合并的并发编辑将产生冲突,详见 §6 合并。
4. Version Blob:身份版本的 JSON 序列化
每个versionblob 是一个 JSON 对象,字段定义如下:
| 字段 | JSON key | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| 格式版本 | version | integer | 是 | 必须等于2 |
| 姓名 | name | string | 否 | 用户的显示名 |
| 邮箱 | email | string | 否 | 邮箱地址(来自 git config 或 bridge 导入) |
| 登录名 | login | string | 否 | 来自 bridge 的用户名(如 GitHub login) |
| 头像 URL | avatar_url | string | 否 | 头像图片的 URL |
| 公钥列表 | pub_keys | key 对象数组 | 否 | 从本版本起有效的 PGP 公钥集合 |
| Lamport 时钟 | times | object(string→integer) | 是 | 本版本创建时所有已知时钟值的快照(见 §4.1) |
| Unix 时间戳 | unix_time | integer | 是 | 版本创建的墙钟时间(自 epoch 起的秒数) |
| Nonce | nonce | base64 字符串 | 是 | 随机字节(20–64 字节),用于保证首个版本 ID 的唯一性 |
| 元数据 | metadata | object(string→string) | 否 | 任意键值对 |
读取方必须拒绝version字段 ≠ 2 的版本。
源码侧的对应结构体(version.go)清晰展示了字段与 JSON 序列化关系——注意name、email、login、avatar_url、pub_keys、metadata均带omitempty,为空时直接省略;而version、times、unix_time、nonce总是输出:
type versionJSON struct { FormatVersion uint `json:"version"` Times map[string]lamport.Time `json:"times"` UnixTime int64 `json:"unix_time"` Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` Login string `json:"login,omitempty"` AvatarUrl string `json:"avatar_url,omitempty"` Keys []*Key `json:"pub_keys,omitempty"` Nonce []byte `json:"nonce"` Metadata map[string]string `json:"metadata,omitempty"` }版本号不符时的反序列化直接报格式错误(version.go):entity.NewErrInvalidFormat(aux.FormatVersion, formatVersion)。
4.1 Lamport Times Map
times字段记录本身份版本被创建的那一刻,所有实体类型的 Lamport 时钟值。键名遵循<namespace>-create与<namespace>-edit模式(例如bugs-create、bugs-edit)。它的作用是让身份版本可以与其他实体进行时间排序——身份何时被修改,与其他实体(如 bug)的操作之间存在因果先后。
示例:某仓库只含bugs实体类型,其中 14 个 bug 被创建过,最近一次编辑时钟为 137:
"times": { "bugs-create": 14, "bugs-edit": 137 }当仓库新增实体类型(如prs、boards)时,它们的时钟也会出现在这里。读取方必须容忍该 map 中的未知键。
源码层面,newVersion通过repo.AllClocks()一次性抓取所有已知时钟并快照(version.go):
clocks, err := repo.AllClocks() ... times := make(map[string]lamport.Time) for name, clock := range clocks { times[name] = clock.Time() }同时Identity.Validate()(identity.go)会强制时间单调性约束:
- 同一时钟名在新版本中的值不得倒退(non-chronological lamport clock 报错);
- 新版本不得丢弃旧版本已有的时钟键(否则报 "version has less lamport clocks than before")。
4.2 Key 对象与密钥生命周期
[!WARNING]密钥管理尚未完全可用。实体 commit 的数据结构与签名验证逻辑已经就位,但生成、注册、管理密钥的工具链尚不完整。实践中绝大多数身份不携带任何密钥,实体 commit 也未签名。密钥格式(当前为 OpenPGP)将来可能变化。
pub_keys中的每个条目是一个 JSON 字符串,内容为ASCII-armored 的 OpenPGP 公钥(PEM 块类型PGP PUBLIC KEY BLOCK)。读取方必须忽略OpenPGP 密钥中的创建时间字段。
源码实现印证(key.go):
MarshalJSON用armor.Encode(&buf, openpgp.PublicKeyType, nil)将公钥序列化为 armored 文本,再包装为 JSON 字符串;UnmarshalJSON反向解码并校验block.Type != openpgp.PublicKeyType时报 "invalid key type";- 密钥的
CreationTime在往返中被显式置零(public.CreationTime = time.Time{}),与规范"必须忽略创建时间"一致。
pub_keys数组声明的是"从本版本起有效的完整密钥集合",而非相对上一版本的增量。因此:
- 添加密钥:写一个新版本,其
pub_keys包含之前所有有效密钥 + 新密钥; - 吊销密钥:写一个新版本,其
pub_keys省略该密钥; - 空数组:表示从本版本起没有任何有效密钥。
该模型天然支持多密钥并存(例如每个设备一把密钥)与密钥轮换:用户可以先添加新密钥、再吊销旧密钥,避免签名能力的空窗期。
密钥在时间轴上的有效性
由于操作(operation)是在特定 Lamport 时钟时刻创作的,验证签名时应使用操作创作时有效的密钥集合,而非当前密钥集合。查找算法如下:
- 按时间顺序遍历身份版本;
- 对每个版本读取其
times[<namespace>-edit]值; - 返回最新的、时钟值 ≤ 该操作编辑时钟的版本的
pub_keys。
这意味着:被吊销的密钥对吊销之前签署的操作仍然有效(不会让历史签名失效)。
源码实现为Identity.ValidKeysAtTime(clockName, time)(identity.go),逻辑与规范完全一致:
func (i *Identity) ValidKeysAtTime(clockName string, time lamport.Time) []*Key { var result []*Key var lastTime lamport.Time for _, v := range i.versions { refTime, ok := v.times[clockName] if !ok { refTime = lastTime } lastTime = refTime if refTime > time { return result // 已超出目标时钟,返回此前收集的密钥集 } result = v.keys } return result }该接口同时被声明在 interface.go 中,供实体签名验证环节按需调用。
身份保护(尚未实现)
设计的预期方案是:一旦身份声明了至少一把密钥,其后追加到链上的新版本必须由当前有效密钥之一签名——从而阻止"拥有仓库写权限的攻击者静默添加恶意密钥或替换密钥集合"。目前该机制未实现:身份版本 commit 当前未签名,读取时也不做此类校验。
源码中IsProtected()目前恒返回false(identity.go),注释明确写着 "Todo"。
4.3 Metadata:桥接(Bridge)元数据
metadata字段是一个任意的字符串键值存储,主要由各 bridge 用来记录用户在远端平台的身份信息。键名遵循约定<bridge-name>-<field>。
内置 bridge 写入的已知键:
| 键 | 由谁写入 | 说明 |
|---|---|---|
github-login | GitHub bridge | 用户的 GitHub 用户名 |
gitlab-login | GitLab bridge | 用户的 GitLab 用户名 |
jira-login | Jira bridge | 用户的 Jira 用户名 |
jira-user | Jira bridge | 用户的 Jira 用户键(Jira 内部标识) |
launchpad-login | Launchpad bridge | 用户的 Launchpad 用户名 |
第三方若添加新的 bridge 专属键,应以自己的 bridge 名作前缀以避免冲突。
源码侧,版本级元数据通过version.SetMetadata/GetMetadata/AllMetadata管理(version.go),身份级聚合则在 identity.go 中实现:
SetMetadata:若最后版本已提交(commitHash != ""),会先克隆追加一个新版本再写元数据(已提交数据不可变);ImmutableMetadata:跨版本累积,首个定义的值优先(first defined takes precedence);MutableMetadata:跨版本累积,最后定义的值优先(last defined takes precedence)。
4.4 示例 Version Blob
最小版本(无密钥、无 bridge 字段):
{ "version": 2, "times": {"bugs-create": 3, "bugs-edit": 7}, "unix_time": 1609459200, "name": "Alice", "email": "alice@example.com", "nonce": "rv5N8TwqB3sGKhBxVoNFPw==" }带公钥和 bridge 登录名的版本:
{ "version": 2, "times": {"bugs-create": 5, "bugs-edit": 12}, "unix_time": 1612137600, "name": "Alice", "email": "alice@example.com", "login": "alice-gh", "pub_keys": [ "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----\n" ], "nonce": "9k3mP1QrX8aLuYoW5NcTzA==" }读取方必须把缺失的可选字段视为空/零值。login、avatar_url、pub_keys、metadata在空值时从 JSON 中省略(omitempty,见上文versionJSON结构体定义)。
关于 nonce 的补充约束来自 version.go 与Validate()(version.go):长度必须> 20 且 < 64 字节(实际生成时使用makeNonce(20),即 20 字节随机数),其作用是给"首个版本"的数据注入足够熵,保证 ID 的随机唯一性——它没有其他功能用途,读取时应忽略。
version.Validate()还包含这些写入前必须满足的约束(version.go):
name与login至少设置一个("either name or login should be set");name、login、email必须为安全单行文本(text.SafeOneLine);avatar_url非空时必须是合法 URL(text.ValidUrl);- 每个 key 必须通过
Key.Validate()(公钥非空、且具备CanSign()能力,见 key.go)。
5. 身份 ID 派生(Identity ID Derivation)
身份的 ID 是第一个版本的 JSON blob 的精确字节的 SHA-256 哈希:
identity-id = hex(sha256(first_version_blob_bytes))读取方必须校验:git reference 中编码的 ID 与由第一个版本 blob 推导出的 ID 必须一致,不一致则必须拒绝该身份。
源码中两层校验逐级落实:
- 通用派生函数(entity/id.go)——
DeriveId对任意序列化字节计算 SHA-256 并转为 64 位小写十六进制:func DeriveId(data []byte) Id { sum := sha256.Sum256(data) return Id(fmt.Sprintf("%x", sum)) } - 版本级计算(version.go)——
version.Id()在未设置时预测序列化字节并调用DeriveId;Write()在真正落盘写入 blob 后再次用实际字节设置 ID。由于"预测"与"写入"走的是同一段序列化代码,两者必然一致。 - 身份级核对(identity.go)——读取完成后比对:
if id != i.versions[0].Id() { return nil, fmt.Errorf("identity ID doesn't math the first version ID") }
通用 ID 派生规则可参考 dag-entity.md §7。另外注意 entity/id.go 中Validate()的一个特殊分支:40 位长度的 ID 会被识别为旧版仓库格式,提示使用迁移工具升级(这与仓库格式版本演进相关,但不在本文身份规范范围内)。
6. 合并(Merge):Fast-Forward Only
身份的合并策略仅允许 fast-forward。
当拉取(fetch)一个远端身份时:
- 若远端 tip 是本地 tip 的祖先(或二者相同):不采取任何操作;
- 若本地 tip 是远端 tip 的祖先:本地引用前进到远端 tip(即 fast-forward);
- 若两端互不为祖先(发生并发编辑):合并失败并返回错误,冲突必须人工解决。
该策略的设计理由是:身份应受单一用户控制。两个独立仓库对同一身份的并发编辑属于异常情况,静默合并可能导致密钥集合不一致(例如丢失吊销或混入未经同意的密钥)。
源码中的实现与设计注释(identity.go):
// To make sure that an Identity history can't be altered, a strict fast-forward // only policy is applied here. ... if i.versions[j].commitHash != otherVersion.commitHash { return false, ErrNonFastForwardMerge }错误类型为ErrNonFastForwardMerge(identity.go)。函数头部的大段注释还记录了一个被否决的替代方案(基于 Lamport 时间的确定性 rebase),其否决原因是:在密钥被攻破的场景下,攻击者可以伪造带虚假 Lamport 时间的新版本插入到合法版本之前,从而劫持身份——因此最终选择了严格的 fast-forward 策略。
配合identity_actions.go提供的同步原语(identity_actions.go),完整的远端同步流程是:
Fetch:repo.FetchRefs(remote, Namespace)拉取远端引用,不改动本地状态;MergeAll:枚举所有refs/remotes/<remote>/identities/引用,逐个校验 ID、读取远端身份、校验数据,然后按"本地不存在则直接复制引用(MergeNewStatus)/ 存在则尝试 Merge(MergeUpdatedStatus 或 MergeNothingStatus)/ 失败则 MergeInvalidStatus"处理;Pull:Fetch + MergeAll,任一合并失败即返回错误;Push:repo.PushRefs(remote, Namespace)推送本地变更;Remove/RemoveAll:删除本地及所有远端跟踪引用(幂等)。
7. 签名验证时的密钥查找(Key Lookup)
要验证某个实体包(entity pack)的 commit 签名,读取方需查找作者的 identity,并确定该包编辑时钟时刻有效的那组密钥:
- 按顺序读取作者的身份版本;
- 找到
times[<namespace>-edit]值 ≤ 该包编辑时钟的最新版本; - 该版本的
pub_keys即验证用的有效密钥集合。
这与 §4.2 的密钥时间有效性算法 是同一逻辑的两种表述(一个是按身份版本查询,一个是按操作时刻查询),对应实现均为Identity.ValidKeysAtTime(clockName string, time lamport.Time) []*Key(identity.go,interface.go)。
补充说明:SigningKey(repo)(identity.go)负责在需要签发时,从当前有效密钥中挑选一把能在 keyring(repository/keyring.go)中找到对应私钥的密钥;私钥的存储与加载实现见 key.go(storePrivate写入 armored 私钥,loadPrivate按KeyIdString()从 keyring 读取)。由于 §4.2 的警告 所述的工具链尚未完备,实践中签名路径很少被触发。
8. 已知局限(Known Limitations)
- 仓库本地身份(Repository-local identities):身份 ID 由内容派生,而非来自全局注册表,因此同一个人在不同仓库中可能拥有不同身份;目前没有内置机制来断言跨仓库身份等价。
- 不支持并发编辑(No concurrent edit support):fast-forward-only 合并策略意味着,如果同一身份在两个仓库中未先同步就各自编辑,其中一次编辑会被拒绝。在多台机器上编辑身份的用户,应在编辑前先同步。
这两条局限的根源都可追溯到 §6 的合并策略 与 §5 的 ID 派生:前者决定了并发冲突必然失败,后者决定了 ID 天然绑定到具体仓库的字节内容。
9. 测试向量(Test Vectors)
本数据层的测试向量位于testdata/identity.json,涵盖三类用例:
id_derivation:验证"身份 ID = 首个版本 blob 精确字节的 SHA-256";expected_id由参考实现(Go 的encoding/json+crypto/sha256)计算,可通过go test ./doc/spec/... -run TestVectors生成校验;tree_entries:验证任意身份 commit 的 tree 结构——恰好一个名为version的 blob 条目,无时钟条目、无额外子树;version_examples:最小合法版本与带公钥版本的 JSON 示例,并注释了omitempty行为与"OpenPGP 创建时间置零并忽略"的要求。
如需了解身份格式在整个数据模型中的定位(为什么用操作而非快照、为什么用 Lamport 时钟、为什么用 git 对象),可阅读 数据模型设计文档;该层与其他实体共用层的差异对照见 格式规范总览。
参考文件索引
- 规范正文:doc/spec/identity.md
- 身份核心实现:entities/identity/identity.go、entities/identity/version.go、entities/identity/key.go
- 身份接口与桩实现:entities/identity/interface.go、entities/identity/identity_stub.go
- 远端同步动作:entities/identity/identity_actions.go
- ID 派生通用实现:entity/id.go
- 测试向量:doc/spec/testdata/identity.json
- 相关规范:doc/spec/dag-entity.md、doc/spec/README.md
【免费下载链接】git-bugDistributed, offline-first bug tracker embedded in git项目地址: https://gitcode.com/GitHub_Trending/gi/git-bug
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考