构建透明密钥服务器
19 Dec 2025
今天我们准备构建一个用于查询age公钥的密钥服务器。这部分本身并不新奇。有趣的是,我们将应用与Go校验和数据库相同的透明日志技术,来确保密钥服务器运营者的诚实性,使其无法暗中注入恶意密钥,同时仍能保护用户隐私并提供流畅的用户体验。你可以在keyserver.geomys.org查看最终成果。我们将逐步构建它,使用tlog生态系统中的现代工具,在不到500行代码中集成透明性。
我非常兴奋能撰写这篇文章:它展示了我坚信是保护用户和让中心化服务承担责任的关键技术,并且这是多年来我个人、Google的TrustFabric团队、Glasklar的Sigsum团队以及许多其他人员共同努力的成果。
本文也在Transparency.dev社区博客上交叉发布。
让我们从定义目标开始:我们希望有一种安全且便捷的方式来获取其他人及服务的age公钥。实现这一目标最简单且最易用的方式是构建一个中心化的密钥服务器:一个Web服务,用户通过电子邮件地址登录来设置其公钥,其他人则可以通过电子邮件地址查询公钥。
信任运营密钥服务器的第三方,你可以通过委托检查电子邮件所有权和实施速率限制的责任来解决身份验证、认证和垃圾邮件问题。密钥服务器可以向电子邮件地址发送一个链接,任何收到该链接的人都有权管理与该地址绑定的公钥。
我让Claude Code构建了基础服务,因为它很简单,并且不是我们今天要做的有趣部分。实现上没有什么特别之处:只是一个Go服务器、一个SQLite数据库、一个查询API、一个受验证码保护并通过发送电子邮件认证链接来设置的API,以及一个调用查询API的Go CLI。
透明日志与中心化服务的问责制
许多问题都呈现这种形态,并且通过受信任的第三方更容易解决:PKI、软件包注册中心、投票系统……有时受信任的第三方被封装在间接层后面,我们称之为证书颁发机构,但概念是相同的。
中心化如此吸引人,以至于OpenPGP生态系统也接纳了它:在SKS池因垃圾邮件而被关闭后,一个新的OpenPGP密钥服务器被构建出来,它仅仅是一个中心化的、经过电子邮件认证的公钥数据库。其FAQ声称他们不希望成为CA,但也解释说他们完全不支持(效果存疑的)信任网络,因此实际上他们只能充当受信任的第三方。
受信任第三方的明显缺点就是信任。你需要信任运营者,还需要信任未来可能控制运营者的人,以及运营者的安全实践。这要求很高,尤其是在当今时代,一个恶意或被攻陷的密钥服务器可以向特定受害者提供虚假公钥,而且几乎不可能被发现。
透明日志是一种在不牺牲用户体验的情况下,为中心化系统应用加密问责制的技术。
透明日志或tlog是一个仅追加的、全局一致的条目列表,具有高效的包含性和一致性加密证明。日志运营者向日志追加条目,这些条目可以是像(package, version, hash)或(email, public key)这样的元组。客户端在接收条目之前验证包含证明,这保证了日志运营者将永久且向全世界承诺该条目,无法隐藏或否认它。只要某个能够检查条目真实性的人最终检查(或“监控”)日志,客户端就可以相信不当行为会被发现。
实际上,tlog让日志运营者以其声誉作为赌注,为集体、可能是手动验证日志条目争取时间。这是在像信任网络这样不切实际的本地验证机制和像集中式X.509 PKI这样完全信任的机制之间的折中方案。
如果你想了解更多介绍,我在Real World Crypto 2024的演讲介绍了现代透明日志的技术功能和抽象概念。
围绕C2SP规范,有一整套可互操作的tlog工具和公开可用的基础设施生态系统。这就是我们今天将要用来为密钥服务器添加tlog的内容。
如果你想了解tlog生态系统的最新动态,我的2025 Transparency.dev峰会主题演讲概述了工具、应用和规范。
tlog与证书透明度及密钥透明度的对比
如果你熟悉证书透明度,tlog源自CT,但有一些重大差异。最重要的是,没有单独的条目生产者(在CT中是CA)和日志运营者;此外,客户端检查实际的包含证明而不是SCT;最后,有更强的防分视图保护,我们将在下面看到。静态CT API和Sunlight CT日志实现是朝着tlog生态系统迈进的第一步,一个名为Merkle Tree Certificates的提案设计重新设计了WebPKI,使其具有类似tlog且与tlog互操作的透明性。
根据我的经验,在学习tlog时最好不要考虑CT。一个更好的tlog生产示例是Go校验和数据库,Google在其中记录了Go Modules Proxy观察到的每个模块版本的模块名称、版本和哈希值。模块获取是通过常规HTTPS进行的,因此没有公开可验证的真实性证明。相反,中心方将每次观察结果追加到tlog中,以便任何不当行为都能被捕获。go get命令为下载的每个模块验证包含证明,保护100%的生态系统,而不需要模块作者管理密钥。
Katie Hockman在GopherCon 2019上就Go校验和数据库做了一个精彩的演讲。
你可能也听说过密钥透明度。KT是一项重叠的技术,已被Apple、WhatsApp和Signal等公司部署。它有类似的目标,但做出了不同的权衡取舍,涉及显著更高的复杂性,以换取在某些场景下更好的隐私和可扩展性。
为我们密钥服务器设计的tlog
那么,我们如何将tlog应用到基于电子邮件的密钥服务器上呢?
这相当简单,我们可以使用Tessera和Torchwood通过一个250行的差异来做到。Tessera是一个通用的tlog实现库,可以用对象存储或POSIX文件系统作为后端。对于我们的密钥服务器,我们将使用后者,它根据c2sp.org/tlog-tiles规范将整个tlog存储在一个目录中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
s, err := note.NewSigner(os.Getenv("LOG_KEY"))
if err != nil {
log.Fatalln("failed to create checkpoint signer:", err)
}
v, err := torchwood.NewVerifierFromSigner(os.Getenv("LOG_KEY"))
if err != nil {
log.Fatalln("failed to create checkpoint verifier:", err)
}
policy := torchwood.ThresholdPolicy(2, torchwood.OriginPolicy(v.Name()),
torchwood.SingleVerifierPolicy(v))
driver, err := posix.New(ctx, posix.Config{
Path: *logPath,
})
if err != nil {
log.Fatalln("failed to create log storage driver:", err)
}
// Since this is a low-traffic but interactive server, disable batching to
// remove integration latency for the first request. Keep a 1s checkpoint
// interval not to hit the witnesses too often; this will be observed only
// if two requests come in quick succession. Finally, only publish a
// checkpoint once a day if there are no new entries, making the average qps
// on witnesses low. Poll for new checkpoints quickly since it should be
// just a read from a hot filesystem cache.
checkpointInterval := 1 * time.Second
if testing.Testing() {
checkpointInterval = 100 * time.Millisecond
}
appender, shutdown, logReader, err := tessera.NewAppender(ctx, driver, tessera.NewAppendOptions().
WithCheckpointSigner(s).
WithBatching(1, tessera.DefaultBatchMaxAge).
WithCheckpointInterval(checkpointInterval).
WithCheckpointRepublishInterval(24*time.Hour))
if err != nil {
log.Fatalln("failed to create log appender:", err)
}
defer shutdown(context.Background())
awaiter := tessera.NewPublicationAwaiter(ctx, logReader.ReadCheckpoint, 25*time.Millisecond)
|
每次用户设置密钥时,我们将一个编码的(email, public key)条目追加到tlog中,并将tlog条目索引存储在数据库中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// Add to transparency log
if strings.ContainsAny(email, "\n") {
http.Error(w, "Invalid email format", http.StatusBadRequest)
return
}
entry := tessera.NewEntry(fmt.Appendf(nil, "%s\n%s\n", email, pubkey))
index, _, err := s.awaiter.Await(r.Context(), s.appender.Add(r.Context(), entry))
if err != nil {
http.Error(w, "Failed to add to transparency log", http.StatusInternalServerError)
log.Printf("transparency log error: %v", err)
return
}
// Store in database
if err := s.storeKey(email, pubkey, int64(index.Index)); err != nil {
http.Error(w, "Failed to store key", http.StatusInternalServerError)
log.Printf("database error: %v", err)
return
}
|
查询API根据索引生成证明并提供给客户端。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
func (s *Server) makeSpicySignature(ctx context.Context, index int64) ([]byte, error) {
checkpoint, err := s.reader.ReadCheckpoint(ctx)
if err != nil {
return nil, fmt.Errorf("failed to read checkpoint: %v", err)
}
c, _, err := torchwood.VerifyCheckpoint(checkpoint, s.policy)
if err != nil {
return nil, fmt.Errorf("failed to parse checkpoint: %v", err)
}
p, err := tlog.ProveRecord(c.N, index, torchwood.TileHashReaderWithContext(
ctx, c.Tree, tesserax.NewTileReader(s.reader)))
if err != nil {
return nil, fmt.Errorf("failed to create proof: %v", err)
}
return torchwood.FormatProof(index, p, checkpoint), nil
}
|
该证明遵循c2sp.org/tlog-proof规范。它看起来像这样:
1
2
3
4
5
6
7
8
9
|
c2sp.org/tlog-proof@v1
index 1
CJdjppwZSa2A60oEpcdj/OFjVQyrkP3fu/Ot2r6smg0=
keyserver.geomys.org
2
HtFreYGe2VBtaf3Vf0AG0DAwEZ+H92HQqrx4dkrzk0U=
— keyserver.geomys.org FrMVCWmHnYfHReztLams2F3HUY6UMub3c5xu7+e8R8SAk9cxPKAB1fsQ6gFM16xwkvZ8p5aWaBf8km+M20eHErSfGwI=
|
它结合了检查点(日志在特定大小下的签名快照)、条目在日志中的索引以及条目在该检查点中的包含证明。
客户端CLI从查询API接收证明,使用内置的日志公钥检查检查点上的签名,哈希预期的条目,并检查该哈希和检查点的包含证明。它可以在不与日志进一步交互的情况下完成所有这些操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
vkey := os.Getenv("AGE_KEYSERVER_PUBKEY")
if vkey == "" {
vkey = defaultKeyserverPubkey
}
v, err := note.NewVerifier(vkey)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid keyserver public key: %v\n", err)
os.Exit(1)
}
policy := torchwood.ThresholdPolicy(2, torchwood.OriginPolicy(v.Name()),
torchwood.SingleVerifierPolicy(v))
// Verify spicy signature
entry := fmt.Appendf(nil, "%s\n%s\n", result.Email, result.Pubkey)
if err := torchwood.VerifyProof(policy, tlog.RecordHash(entry), []byte(result.Proof)); err != nil {
return "", fmt.Errorf("failed to verify key proof: %w", err)
}
|
如果你仔细观察,可以看到证明本质上是一个条目的“增强签名”,你可以使用日志的公钥进行验证,就像验证消息的Ed25519或RSA签名一样。我喜欢称它们为增强签名,以强调tlog可以部署在你可以部署常规数字签名的任何地方。
监控
那么所有这些的意义何在呢?意义在于任何人都可以浏览日志,以确保密钥服务器没有为其电子邮件地址提供未经授权的密钥!确实,就像没有恢复的备份是无用的,没有验证的签名是无用的一样,没有监控的tlog也是无用的。这意味着我们需要构建工具来监控日志。
在服务器端,只需要两行代码来暴露Tessera POSIX日志目录。
1
2
3
|
// Serve tlog-tiles log
fs := http.StripPrefix("/tlog/", http.FileServer(http.Dir(*logPath)))
mux.Handle("GET /tlog/", fs)
|
在客户端,我们向CLI添加一个-all标志,用于读取日志中所有匹配的条目。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
func monitorLog(serverURL string, policy torchwood.Policy, email string) ([]string, error) {
f, err := torchwood.NewTileFetcher(serverURL+"/tlog", torchwood.WithUserAgent("age-keylookup/1.0"))
if err != nil {
return nil, fmt.Errorf("failed to create tile fetcher: %w", err)
}
c, err := torchwood.NewClient(f)
if err != nil {
return nil, fmt.Errorf("failed to create torchwood client: %w", err)
}
// Fetch and verify checkpoint
signedCheckpoint, err := f.ReadEndpoint(context.Background(), "checkpoint")
if err != nil {
return nil, fmt.Errorf("failed to read checkpoint: %w", err)
}
checkpoint, _, err := torchwood.VerifyCheckpoint(signedCheckpoint, policy)
if err != nil {
return nil, fmt.Errorf("failed to parse checkpoint: %w", err)
}
// Fetch all entries up to the checkpoint size
var pubkeys []string
for i, entry := range c.AllEntries(context.Background(), checkpoint.Tree, 0) {
e, rest, ok := strings.Cut(string(entry), "\n")
if !ok {
return nil, fmt.Errorf("malformed log entry %d: %q", i, string(entry))
}
k, rest, ok := strings.Cut(rest, "\n")
if !ok || rest != "" {
return nil, fmt.Errorf("malformed log entry %d: %q", i, string(entry))
}
if e == email {
pubkeys = append(pubkeys, k)
}
}
if c.Err() != nil {
return nil, fmt.Errorf("error fetching log entries: %w", c.Err())
}
return pubkeys, nil
}
|
为了实现有效的监控,我们还会通过去除空格和小写化来规范化电子邮件地址,因为用户不太可能监控所有变体。我们在发送登录链接之前进行规范化,这样规范化就不会导致冒充。
1
2
|
// Normalize email
email = strings.TrimSpace(strings.ToLower(email))
|
完整的监控方案将涉及第三方服务为你监控日志,并在添加新密钥时通过电子邮件通知你,就像gopherwatch和Source Spotter为Go校验和数据库所做的那样,但-all标志是一个开始。
完整的更改涉及5个文件被修改,增加了251行,删除了6行,外加测试,并包括一个新的keygen辅助二进制文件、所需的数据库架构和帮助文本及API更改,以及显示证明的Web UI更改。
编辑:原始补丁系列缺少监控模式下的新鲜度检查,以确保日志没有通过向监控者提供旧检查点来隐藏条目。最简单的解决方案是检查见证人联名签名上的时间戳(+15行)。你将在下面了解见证人联名签名。
使用VRF保护隐私
然而,我们通过实现这个tlog产生了一个问题:现在我们所有用户的电子邮件地址都公开了!虽然这对于Go校验和数据库中的模块名称来说是可以接受的,但允许在我们的密钥服务器中枚举电子邮件地址对于隐私和垃圾邮件来说是不可接受的。
我们可以对电子邮件地址进行哈希处理,但这仍然允许离线暴力攻击。合适的工具是可验证随机函数。你可以将VRF视为一种带有私钥和公钥的哈希:只有你可以使用私钥生成哈希值,但任何人都可以使用公钥检查它是否是正确的(且唯一的)哈希值。
总的来说,使用基于ristretto255的c2sp.org/vrf-r255实例化,通过filippo.io/mostly-harmless/vrf-r255(等待更永久的地址)实现,实施VRF只需不到130行。我们在日志条目中包含VRF哈希值,而不是电子邮件地址,并将VRF证明保存在数据库中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// Compute VRF hash and proof
vrfProof := s.vrf.Prove([]byte(email))
vrfHash := base64.StdEncoding.EncodeToString(vrfProof.Hash())
// Add to transparency log
entry := tessera.NewEntry(fmt.Appendf(nil, "%s\n%s\n", vrfHash, pubkey))
index, _, err := s.awaiter.Await(r.Context(), s.appender.Add(r.Context(), entry))
if err != nil {
http.Error(w, "Failed to add to transparency log", http.StatusInternalServerError)
}
// [...]
// Store in database
if err := s.storeKey(email, pubkey, int64(index.Index), vrfProof.Bytes()); err != nil {
http.Error(w, "Failed to store key", http.StatusInternalServerError)
log.Printf("database error: %v", err)
return
}
|
tlog证明格式有空间存储应用特定的不透明额外数据,因此我们可以将VRF证明存储在那里,以保持tlog证明的自包含性。
1
|
return torchwood.FormatProofWithExtraData(index, vrfProof, p, checkpoint), nil
|
在客户端CLI中,我们从tlog证明的额外数据中提取VRF哈希,并验证它是否为电子邮件地址的正确哈希。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// Compute and verify VRF hash
vrfProofBytes, err := torchwood.ProofExtraData([]byte(result.Proof))
if err != nil {
return "", fmt.Errorf("failed to extract VRF proof: %w", err)
}
vrfProof, err := vrf.NewProof(vrfProofBytes)
if err != nil {
return "", fmt.Errorf("failed to parse VRF proof: %w", err)
}
vrfHash, err := vrfKey.Verify(vrfProof, []byte(email))
if err != nil {
return "", fmt.Errorf("failed to verify VRF proof: %w", err)
}
// Verify spicy signature
vrfHashB64 := base64.StdEncoding.EncodeToString(vrfHash)
entry := fmt.Appendf(nil, "%s\n%s\n", vrfHashB64, result.Pubkey)
if err := torchwood.VerifyProof(policy, tlog.RecordHash(entry), []byte(result.Proof)); err != nil {
return "", fmt.Errorf("failed to verify key proof: %w", err)
}
|
那么我们现在如何监控呢?我们需要添加一个新的API来为电子邮件地址提供VRF哈希(和证明)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
mux.HandleFunc("GET /manage", srv.handleManage)
mux.HandleFunc("POST /setkey", srv.handleSetKey)
mux.HandleFunc("GET /api/lookup", srv.handleLookup)
mux.HandleFunc("GET /api/monitor", srv.handleMonitor)
mux.HandleFunc("POST /api/verify-token", srv.handleVerifyToken)
func (s *Server) handleMonitor(w http.ResponseWriter, r *http.Request) {
email := r.URL.Query().Get("email")
if email == "" {
http.Error(w, "Email parameter required", http.StatusBadRequest)
return
}
// Return as JSON
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"email": email,
"vrf_proof": s.vrf.Prove([]byte(email)).Bytes(),
})
}
|
在客户端,我们使用该API获取VRF证明,验证它,然后在日志中查找VRF哈希,而不是查找电子邮件地址。
攻击者仍然可以通过访问公共查询或监控API来枚举电子邮件地址,但他们一直都能这样做:提供这样的公共API正是密钥服务器的目的!使用VRF,我们恢复了原始状态:枚举需要通过在线、速率受限的API进行暴力破解,而不是在tlog中拥有完整的电子邮件地址列表(或可以离线暴力破解的哈希值)。
VRF还有进一步的好处:如果用户请求从服务中删除其信息,我们无法从tlog中移除他们的条目,但我们可以停止从查询和监控API提供其电子邮件地址的VRF。这使得无法获取该用户的密钥历史记录,甚至无法检查他们是否曾经使用过密钥服务器,但不会影响对其他用户的监控。
添加VRF的完整更改涉及3个文件被修改,增加了125行,删除了13行,外加测试。
防投毒攻击
我们还有一个边缘风险需要缓解:既然我们永远无法从tlog中移除条目,如果有人通过将不良信息伪装成公钥插入日志中怎么办,比如age1llllllllllllllrustevangellsmstrlkef0rcellllllllllllq574n08?
防范这种风险被称为防投毒攻击。对我们日志的风险相对较小,公钥必须是Bech32编码且简短,因此攻击者无法有效地嵌入图像或恶意软件。尽管如此,中和它很容易:我们不在tlog条目中放入公钥,而是放入它们的哈希值,将原始公钥保留在数据库的新表中,并通过监控API提供它们。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// Compute VRF hash and proof
vrfProof := s.vrf.Prove([]byte(email))
// Keep track of the unhashed key
if err := s.storeHistory(email, pubkey); err != nil {
http.Error(w, "Failed to store key history", http.StatusInternalServerError)
log.Printf("database error: %v", err)
return
}
// Add to transparency log
h := sha256.New()
h.Write([]byte(pubkey))
entry := tessera.NewEntry(h.Sum(vrfProof.Hash())) // vrf-r255(email) || SHA-256(pubkey)
index, _, err := s.awaiter.Await(r.Context(), s.appender.Add(r.Context(), entry))
|
非常重要的一点是,在将条目添加到tlog之前,我们将原始密钥保存在数据库中。丢失原始密钥与拒绝向监控者提供恶意密钥是无法区分的。
在客户端,要进行查询,我们只需在验证包含证明时对公钥进行哈希。要在-all模式下进行监控,我们将哈希值与服务器通过监控API提供的原始公钥列表进行匹配。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
var result struct {
Email string `json:"email"`
VRFProof []byte `json:"vrf_proof"`
History []string `json:"history"`
}
// Prepare map of hashes of historical keys
historyHashes := make(map[[32]byte]string)
for _, pk := range result.History {
h := sha256.Sum256([]byte(pk))
historyHashes[h] = pk
}
// Fetch all entries up to the checkpoint size
var pubkeys []string
for i, entry := range c.AllEntries(context.Background(), checkpoint.Tree, 0) {
if len(entry) != 64+32 {
return nil, fmt.Errorf("invalid entry size at index %d", i)
}
if !bytes.Equal(entry[:64], vrfHash) {
continue
}
pk, ok := historyHashes[([32]byte)(entry[64:])]
if !ok {
return nil, fmt.Errorf("found unknown public key hash in log at index %d", i)
}
pubkeys = append(pubkeys, pk)
}
|
我们最终的日志条目格式是vrf-r255(email) || SHA-256(pubkey)。设计tlog条目是部署tlog最重要的部分:它需要包含足够的信息让监控者隔离所有与他们相关的条目,但又不能包含过多信息以至于构成隐私或投毒威胁。
提供防投毒保护的完整更改涉及2个文件被修改,增加了93行,删除了19行,外加测试。
防抵赖与见证网络
我们快完成了!还有一件事要修复,而这曾经是最困难的部分。
为了获得我们所需的延迟、集体验证,所有客户端和监控者必须看到同一个日志的一致视图,其中日志保持其仅追加属性。这被称为防抵赖或防分视图保护。换句话说,我们如何阻止日志运营者向客户端显示日志A的包含证明,然后向监控者显示不同的日志B?
就像没有监控的日志记录就像没有验证的签名一样,没有防抵赖措施的日志记录只是一个复杂的签名算法,没有强大的透明属性。
这是困难的部分,因为在一般情况下你无法独自完成。相反,tlog生态系统有见证人联名签名者的概念:由第三方运营的服务会联名签署一个检查点,以证明该检查点与他们为该日志观察到的所有其他检查点一致。客户端检查这些见证人联名签名,以获得保证——除非足够多的见证人与日志串通——他们没有被呈现一个分视图的日志。
这些见证人的运营效率极高:日志在请求联名签名时提供O(log N)的一致性证明,见证人只需要存储其观察到的O(1)最新检查点。所有可能密集的验证都推迟并委托给监控者,他们可以确信拥有与所有客户端相同的视图,这得益于见证人联名签名。
这种效率使得作为公共利益基础设施免费运营见证人成为可能。见证网络收集公共见证人,并维护一个见证人自动配置的tlog开放列表。
对于Geomys实例的密钥服务器,我生成了一个tlog密钥,然后向见证网络提交了一个PR,将以下行添加到测试日志列表中。
1
2
3
|
vkey keyserver.geomys.org+16b31509+ARLJ+pmTj78HzTeBj04V+LVfB+GFAQyrg54CRIju7Nn8
qpd 1440
contact keyserver-tlog@geomys.org
|
这使我的日志在几个见证人中得到配置,我从中选择了三个来构建默认的密钥服务器见证人策略。
1
2
3
4
5
6
|
log keyserver.geomys.org+16b31509+ARLJ+pmTj78HzTeBj04V+LVfB+GFAQyrg54CRIju7Nn8
witness TrustFabric transparency.dev/DEV:witness-little-garden+d8042a87+BCtusOxINQNUTN5Oj8HObRkh2yHf/MwYaGX4CPdiVEPM https://api.transparency.dev/dev/witness/little-garden/
witness Mullvad witness.stagemole.eu+67f7aea0+BEqSG3yu9YrmcM3BHvQYTxwFj3uSWakQepafafpUqklv https://witness.stagemole.eu/
witness Geomys witness.navigli.sunlight.geomys.org+a3e00fe2+BNy/co4C1Hn1p+INwJrfUlgz7W55dSZReusH/GhUhJ/G https://witness.navigli.sunlight.geomys.org/
group public 2 TrustFabric Mullvad Geomys
quorum public
|
策略格式基于Sigsum的策略,它编码了日志的公钥以及见证人的公钥(供客户端使用)和提交URL(供日志使用)。
Tessera直接支持这些策略。当生成新的检查点时,它会并行联系所有见证人,并在满足策略后返回检查点。配置很简单,增加的延迟也最小(不到一秒)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
witnessPolicy := defaultWitnessPolicy
if path := os.Getenv("LOG_WITNESS_POLICY"); path != "" {
witnessPolicy, err = os.ReadFile(path)
if err != nil {
log.Fatalln("failed to read witness policy file:", err)
}
}
witnesses, err := tessera.NewWitnessGroupFromPolicy(witnessPolicy)
if err != nil {
log.Fatalln("failed to create witness group from policy:", err)
}
// [...]
appender, shutdown, logReader, err := tessera.NewAppender(ctx, driver, tessera.NewAppendOptions().
WithCheckpointSigner(s).
WithBatching(1, tessera.DefaultBatchMaxAge).
WithCheckpointInterval(checkpointInterval).
WithCheckpointRepublishInterval(24*time.Hour).
WithWitnesses(witnesses, nil))
|
在客户端,我们可以使用Torchwood来解析策略,并直接将其与VerifyProof一起使用,以替代我们之前根据日志公钥手动构建的策略。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
policyBytes := defaultPolicy
if policyPath := os.Getenv("AGE_KEYSERVER_POLICY"); policyPath != "" {
p, err := os.ReadFile(policyPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read policy file: %v\n", err)
os.Exit(1)
}
policyBytes = p
}
policy, err := torchwood.ParsePolicy(policyBytes)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid policy: %v\n", err)
os.Exit(1)
}
|
再次,如果你仔细观察,可以发现正如tlog证明是增强签名一样,策略就是一个增强公钥。验证是一个确定性的、离线的函数,它接受一个策略/公钥和一个证明/签名,就像数字签名验证一样!
策略是一个DAG,可能会变得复杂以满足最严格的可用性要求。例如,你可以要求10个见证人运营者中的3个对检查点进行联名签名,其中每个运营者可以使用N个见证人实例中的任意一个来完成。但请注意,在这种情况下,你需要定期向监控者提供至少10个运营者中8个的所有联名签名,以防止分视图。
实现见证人联名签名的完整更改涉及5个文件被修改,增加了43行,删除了11行,外加测试。
总结
我们从一个简单的中心化电子邮件认证密钥服务器开始,并将其转变为一个透明的、保护隐私的、防投毒的、经过见证人联名签名的服务。
我们使用Tessera、Torchwood和各种C2SP规范,通过四个小步骤实现了这一点。
1
2
3
4
5
6
7
8
|
cmd/age-keyserver: add transparency log of stored keys
5 files changed, 259 insertions(+), 8 deletions(-)
cmd/age-keyserver: use VRFs to hide emails in the log
3 files changed, 125 insertions(+), 13 deletions(-)
cmd/age-keyserver: hash age public key to prevent log poisoning
2 files changed, 93 insertions(+), 19 deletions(-)
cmd/age-keyserver: add witness cosigning to prevent split-views
5 files changed, 43 insertions(+), 11 deletions(-)
|
总的来说,它用了不到500行代码。
1
|
7 files changed, 472 insertions(+), 9 deletions(-)
|
用户体验完全不变:用户无需管理密钥,Web UI和CLI的工作方式与之前完全相同。唯一的区别是CLI的新-all功能,它允许对日志运营者可能为某个电子邮件地址呈现的所有公钥追究责任。
该成果已部署在keyserver.geomys.org上。
未来工作:高效监控与撤销
这个tlog系统仍然有两个限制:
- 为了监控日志,监控者需要下载全部内容。对于我们的小密钥服务器,甚至对于Go校验和数据库来说,这可能没问题,但对于证书透明度/Merkle Tree Certificates生态系统来说,这是一个可扩展性问题。
- 包含证明保证了公钥在日志中,而不是保证它是该电子邮件地址在日志中的最新条目。同样,Go校验和数据库无法高效证明Go Modules Proxy的
/list响应是完整的。
我们正在研究一种名为可验证索引的设计,它构建在tlog之上,为日志条目提供可验证的索引甚至map-reduce操作。我们预计VI将在2026年底之前投入生产,而上述所有内容今天就已经准备就绪。
即使没有VI,tlog也为我们的密钥服务器提供了强大的问责制,实现了没有透明性就不可能实现的安全用户体验。
我希望这个分步演示能帮助你将tlog应用到你自己的系统中。如果你需要帮助,可以加入Transparency.dev Slack。你可能还想在Bluesky上关注我(@filippo.abyssdomain.expert)或在Mastodon上关注我(@filippo@abyssdomain.expert)。