Skip to content

Commit 0d20b2c

Browse files
committed
cmd/age-keyserver: use VRFs to hide emails in the log
1 parent ef91243 commit 0d20b2c

8 files changed

Lines changed: 138 additions & 13 deletions

File tree

cmd/age-keylookup/main.go

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/base64"
56
"encoding/json"
67
"flag"
78
"fmt"
@@ -12,6 +13,7 @@ import (
1213
"strings"
1314
"time"
1415

16+
"filippo.io/mostly-harmless/vrf-r255"
1517
"filippo.io/torchwood"
1618
"golang.org/x/mod/sumdb/note"
1719
"golang.org/x/mod/sumdb/tlog"
@@ -35,6 +37,7 @@ func main() {
3537
fmt.Fprintf(os.Stderr, "Environment:\n")
3638
fmt.Fprintf(os.Stderr, " AGE_KEYSERVER_URL Default keyserver URL\n")
3739
fmt.Fprintf(os.Stderr, " AGE_KEYSERVER_PUBKEY Default keyserver transparency log vkey\n")
40+
fmt.Fprintf(os.Stderr, " AGE_KEYSERVER_VRFKEY Default keyserver transparency log VRF public key\n")
3841
os.Exit(2)
3942
}
4043

@@ -56,11 +59,26 @@ func main() {
5659
os.Exit(1)
5760
}
5861

62+
vrfKeyB64 := os.Getenv("AGE_KEYSERVER_VRFKEY")
63+
if vrfKeyB64 == "" {
64+
vrfKeyB64 = "vKHX1vKXl7yF0qBiDxCUXWgOHlapMvqFeIBXt7c29iQ="
65+
}
66+
vrfKeyBytes, err := base64.StdEncoding.DecodeString(vrfKeyB64)
67+
if err != nil {
68+
fmt.Fprintf(os.Stderr, "Error: invalid base64 keyserver VRF public key: %v\n", err)
69+
os.Exit(1)
70+
}
71+
vrfKey, err := vrf.NewPublicKey(vrfKeyBytes)
72+
if err != nil {
73+
fmt.Fprintf(os.Stderr, "Error: invalid keyserver VRF public key: %v\n", err)
74+
os.Exit(1)
75+
}
76+
5977
// Normalize email
6078
email = strings.TrimSpace(strings.ToLower(email))
6179

6280
if *allFlag {
63-
pubkeys, err := monitorLog(server, v, email)
81+
pubkeys, err := monitorLog(server, v, vrfKey, email)
6482
if err != nil {
6583
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
6684
os.Exit(1)
@@ -71,7 +89,7 @@ func main() {
7189
return
7290
}
7391

74-
pubkey, err := lookupKey(server, v, email)
92+
pubkey, err := lookupKey(server, v, vrfKey, email)
7593
if err != nil {
7694
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
7795
os.Exit(1)
@@ -80,7 +98,7 @@ func main() {
8098
fmt.Println(pubkey)
8199
}
82100

83-
func lookupKey(serverURL string, v note.Verifier, email string) (string, error) {
101+
func lookupKey(serverURL string, v note.Verifier, vrfKey *vrf.PublicKey, email string) (string, error) {
84102
// Build the lookup URL
85103
lookupURL := serverURL + "/api/lookup?email=" + url.QueryEscape(email)
86104

@@ -124,8 +142,23 @@ func lookupKey(serverURL string, v note.Verifier, email string) (string, error)
124142
return "", fmt.Errorf("empty public key returned")
125143
}
126144

145+
// Compute and verify VRF hash
146+
vrfProofBytes, err := torchwood.HintFromProof([]byte(result.Proof))
147+
if err != nil {
148+
return "", fmt.Errorf("failed to extract VRF proof: %w", err)
149+
}
150+
vrfProof, err := vrf.NewProof(vrfProofBytes)
151+
if err != nil {
152+
return "", fmt.Errorf("failed to parse VRF proof: %w", err)
153+
}
154+
vrfHash, err := vrfKey.Verify(vrfProof, []byte(email))
155+
if err != nil {
156+
return "", fmt.Errorf("failed to verify VRF proof: %w", err)
157+
}
158+
127159
// Verify spicy signature
128-
entry := fmt.Appendf(nil, "%s\n%s\n", result.Email, result.Pubkey)
160+
vrfHashB64 := base64.StdEncoding.EncodeToString(vrfHash)
161+
entry := fmt.Appendf(nil, "%s\n%s\n", vrfHashB64, result.Pubkey)
129162
if err := torchwood.VerifyProof(v.Name(), func(b []byte) (*note.Note, error) {
130163
return note.Open(b, note.VerifierList(v))
131164
}, tlog.RecordHash(entry), []byte(result.Proof)); err != nil {
@@ -135,7 +168,45 @@ func lookupKey(serverURL string, v note.Verifier, email string) (string, error)
135168
return result.Pubkey, nil
136169
}
137170

138-
func monitorLog(serverURL string, v note.Verifier, email string) ([]string, error) {
171+
func monitorLog(serverURL string, v note.Verifier, vrfKey *vrf.PublicKey, email string) ([]string, error) {
172+
// Request the VRF proof from the monitor endpoint
173+
monitorURL := serverURL + "/api/monitor?email=" + url.QueryEscape(email)
174+
client := &http.Client{
175+
Timeout: 10 * time.Second,
176+
}
177+
resp, err := client.Get(monitorURL)
178+
if err != nil {
179+
return nil, fmt.Errorf("failed to connect to keyserver: %w", err)
180+
}
181+
defer resp.Body.Close()
182+
if resp.StatusCode == http.StatusNotFound {
183+
return nil, fmt.Errorf("no key found for %s", email)
184+
}
185+
if resp.StatusCode != http.StatusOK {
186+
body, _ := io.ReadAll(resp.Body)
187+
return nil, fmt.Errorf("keyserver error: %s - %s", resp.Status, string(body))
188+
}
189+
var result struct {
190+
Email string `json:"email"`
191+
VRFProof []byte `json:"vrf_proof"`
192+
}
193+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
194+
return nil, fmt.Errorf("failed to parse response: %w", err)
195+
}
196+
if result.Email != email {
197+
return nil, fmt.Errorf("keyserver returned unexpected email: %q", result.Email)
198+
}
199+
200+
// Compute and verify VRF hash
201+
vrfProof, err := vrf.NewProof(result.VRFProof)
202+
if err != nil {
203+
return nil, fmt.Errorf("failed to parse VRF proof: %w", err)
204+
}
205+
vrfHash, err := vrfKey.Verify(vrfProof, []byte(email))
206+
if err != nil {
207+
return nil, fmt.Errorf("failed to verify VRF proof: %w", err)
208+
}
209+
139210
f, err := torchwood.NewTileFetcher(serverURL+"/tlog", torchwood.WithUserAgent("age-keylookup/1.0"))
140211
if err != nil {
141212
return nil, fmt.Errorf("failed to create tile fetcher: %w", err)
@@ -170,7 +241,7 @@ func monitorLog(serverURL string, v note.Verifier, email string) ([]string, erro
170241
if !ok || rest != "" {
171242
return nil, fmt.Errorf("malformed log entry %d: %q", i, string(entry))
172243
}
173-
if e == email {
244+
if e == base64.StdEncoding.EncodeToString(vrfHash) {
174245
pubkeys = append(pubkeys, k)
175246
}
176247
}

cmd/age-keyserver-keygen/keygen.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package main
22

33
import (
44
"crypto/rand"
5+
"encoding/base64"
56
"fmt"
67
"os"
78

9+
"filippo.io/mostly-harmless/vrf-r255"
810
"golang.org/x/mod/sumdb/note"
911
)
1012

@@ -21,6 +23,10 @@ func main() {
2123
os.Exit(1)
2224
}
2325

26+
vrfKey := vrf.GenerateKey()
27+
2428
fmt.Printf("Private key (for LOG_KEY in age-keyserver): %s\n", skey)
29+
fmt.Printf("Private VRF key (for VRF_KEY in age-keyserver): %s\n", base64.StdEncoding.EncodeToString(vrfKey.Bytes()))
2530
fmt.Printf("Public key (for AGE_KEYSERVER_PUBKEY in age-keylookup): %s\n", vkey)
31+
fmt.Printf("Public VRF key (for AGE_KEYSERVER_VRFKEY in age-keylookup): %s\n", base64.StdEncoding.EncodeToString(vrfKey.PublicKey().Bytes()))
2632
}

cmd/age-keyserver/main.go

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"time"
2525

2626
"filippo.io/age"
27+
"filippo.io/mostly-harmless/vrf-r255"
2728
"filippo.io/torchwood"
2829
"filippo.io/torchwood/tesserax"
2930
"github.com/transparency-dev/tessera"
@@ -49,6 +50,7 @@ type Server struct {
4950
dbpool *sqlitex.Pool
5051
templates *template.Template
5152
hmacKey []byte
53+
vrf *vrf.PrivateKey
5254
baseURL string
5355
reader tessera.LogReader
5456
appender *tessera.Appender
@@ -60,6 +62,7 @@ type KeyData struct {
6062
Pubkey string `json:"pubkey"`
6163
UpdatedAt int64 `json:"updated_at"`
6264
LogIndex int64 `json:"log_index"`
65+
VRFProof []byte `json:"vrf_proof"`
6366
}
6467

6568
const (
@@ -84,6 +87,15 @@ func main() {
8487
log.Fatalln("failed to create checkpoint verifier:", err)
8588
}
8689

90+
vrfKey, err := base64.StdEncoding.DecodeString(os.Getenv("VRF_KEY"))
91+
if err != nil {
92+
log.Fatalln("failed to decode VRF key:", err)
93+
}
94+
vrf, err := vrf.NewPrivateKey(vrfKey)
95+
if err != nil {
96+
log.Fatalln("failed to create VRF from key:", err)
97+
}
98+
8799
driver, err := posix.New(ctx, posix.Config{
88100
Path: *logPath,
89101
})
@@ -149,6 +161,7 @@ func main() {
149161
dbpool: dbpool,
150162
templates: templates,
151163
hmacKey: hmacKey,
164+
vrf: vrf,
152165
baseURL: baseURL,
153166
reader: logReader,
154167
appender: appender,
@@ -163,6 +176,7 @@ func main() {
163176
mux.HandleFunc("GET /manage", srv.handleManage)
164177
mux.HandleFunc("POST /setkey", srv.handleSetKey)
165178
mux.HandleFunc("GET /api/lookup", srv.handleLookup)
179+
mux.HandleFunc("GET /api/monitor", srv.handleMonitor)
166180
mux.HandleFunc("POST /api/verify-token", srv.handleVerifyToken)
167181

168182
// Serve static files
@@ -338,8 +352,12 @@ func (s *Server) handleSetKey(w http.ResponseWriter, r *http.Request) {
338352
return
339353
}
340354

355+
// Compute VRF hash and proof
356+
vrfProof := s.vrf.Prove([]byte(email))
357+
vrfHash := base64.StdEncoding.EncodeToString(vrfProof.Hash())
358+
341359
// Add to transparency log
342-
entry := tessera.NewEntry(fmt.Appendf(nil, "%s\n%s\n", email, pubkey))
360+
entry := tessera.NewEntry(fmt.Appendf(nil, "%s\n%s\n", vrfHash, pubkey))
343361
index, _, err := s.awaiter.Await(r.Context(), s.appender.Add(r.Context(), entry))
344362
if err != nil {
345363
http.Error(w, "Failed to add to transparency log", http.StatusInternalServerError)
@@ -348,14 +366,14 @@ func (s *Server) handleSetKey(w http.ResponseWriter, r *http.Request) {
348366
}
349367

350368
// Store in database
351-
if err := s.storeKey(email, pubkey, int64(index.Index)); err != nil {
369+
if err := s.storeKey(email, pubkey, int64(index.Index), vrfProof.Bytes()); err != nil {
352370
http.Error(w, "Failed to store key", http.StatusInternalServerError)
353371
log.Printf("database error: %v", err)
354372
return
355373
}
356374

357375
// Generate proof for success page
358-
proofBytes, err := s.makeSpicySignature(r.Context(), int64(index.Index))
376+
proofBytes, err := s.makeSpicySignature(r.Context(), int64(index.Index), vrfProof.Bytes())
359377
if err != nil {
360378
http.Error(w, "Failed to create proof", http.StatusInternalServerError)
361379
log.Printf("proof error: %v", err)
@@ -400,7 +418,7 @@ func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) {
400418
return
401419
}
402420

403-
proof, err := s.makeSpicySignature(r.Context(), data.LogIndex)
421+
proof, err := s.makeSpicySignature(r.Context(), data.LogIndex, data.VRFProof)
404422
if err != nil {
405423
http.Error(w, "Failed to create proof", http.StatusInternalServerError)
406424
log.Printf("proof error: %v", err)
@@ -416,7 +434,22 @@ func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) {
416434
})
417435
}
418436

419-
func (s *Server) makeSpicySignature(ctx context.Context, index int64) ([]byte, error) {
437+
func (s *Server) handleMonitor(w http.ResponseWriter, r *http.Request) {
438+
email := r.URL.Query().Get("email")
439+
if email == "" {
440+
http.Error(w, "Email parameter required", http.StatusBadRequest)
441+
return
442+
}
443+
444+
// Return as JSON
445+
w.Header().Set("Content-Type", "application/json")
446+
json.NewEncoder(w).Encode(map[string]any{
447+
"email": email,
448+
"vrf_proof": s.vrf.Prove([]byte(email)).Bytes(),
449+
})
450+
}
451+
452+
func (s *Server) makeSpicySignature(ctx context.Context, index int64, vrfProof []byte) ([]byte, error) {
420453
checkpoint, err := s.reader.ReadCheckpoint(ctx)
421454
if err != nil {
422455
return nil, fmt.Errorf("failed to read checkpoint: %v", err)
@@ -434,7 +467,7 @@ func (s *Server) makeSpicySignature(ctx context.Context, index int64) ([]byte, e
434467
if err != nil {
435468
return nil, fmt.Errorf("failed to create proof: %v", err)
436469
}
437-
return torchwood.FormatProof(index, p, checkpoint), nil
470+
return torchwood.FormatProofWithHint(index, vrfProof, p, checkpoint), nil
438471
}
439472

440473
func (s *Server) generateHMAC(email string, ts int64) string {
@@ -507,11 +540,12 @@ func (s *Server) getKeyData(email string) (*KeyData, error) {
507540
return &data, nil
508541
}
509542

510-
func (s *Server) storeKey(email, pubkey string, index int64) error {
543+
func (s *Server) storeKey(email, pubkey string, index int64, vrfProof []byte) error {
511544
data := KeyData{
512545
Pubkey: pubkey,
513546
UpdatedAt: time.Now().Unix(),
514547
LogIndex: index,
548+
VRFProof: vrfProof,
515549
}
516550

517551
jsonData, err := json.Marshal(data)

cmd/age-keyserver/testdata/age-keylookup.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ env HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000
55
env AGE_KEYSERVER_URL=http://localhost:13893
66
env AGE_KEYSERVER_HMAC_FILE=$WORK/hmac.txt
77
env LOG_KEY=PRIVATE+KEY+example.com+5800330c+AaAoObvamoDOmN6c30Xh9pH1e/xqKcsU+fNmthQ8qmvM
8+
env VRF_KEY=vni5C6++aVMFR5tg3bwvLamWlhJEmVrtNT7uNeyo6gQ=
89
env AGE_KEYSERVER_PUBKEY=example.com+5800330c+ARPRGiaIwfx6xka5nXhdD/rqojPMjrjhm7OCuy+03Ymz
10+
env AGE_KEYSERVER_VRFKEY=cmJCh5QTwp9VqN+QVV+BRxKLKmCFRuVAx+dahotxqw0=
911
exec age-keyserver -db=$WORK/test.sqlite3 -listen=localhost:13893 &srv&
1012
waitfor http://localhost:13893/
1113

cmd/age-keyserver/testdata/age-keyserver.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# start age-keyserver with test hCaptcha secret
22
env HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000
33
env LOG_KEY=PRIVATE+KEY+example.com+5800330c+AaAoObvamoDOmN6c30Xh9pH1e/xqKcsU+fNmthQ8qmvM
4+
env VRF_KEY=vni5C6++aVMFR5tg3bwvLamWlhJEmVrtNT7uNeyo6gQ=
45
exec age-keyserver -db=$WORK/test.sqlite3 -listen=localhost:13892 &srv&
56
waitfor http://localhost:13892/
67

cmd/age-keyserver/testdata/monitor.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ env HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000
55
env AGE_KEYSERVER_URL=http://localhost:13894
66
env AGE_KEYSERVER_HMAC_FILE=$WORK/hmac.txt
77
env LOG_KEY=PRIVATE+KEY+example.com+5800330c+AaAoObvamoDOmN6c30Xh9pH1e/xqKcsU+fNmthQ8qmvM
8+
env VRF_KEY=vni5C6++aVMFR5tg3bwvLamWlhJEmVrtNT7uNeyo6gQ=
89
env AGE_KEYSERVER_PUBKEY=example.com+5800330c+ARPRGiaIwfx6xka5nXhdD/rqojPMjrjhm7OCuy+03Ymz
10+
env AGE_KEYSERVER_VRFKEY=cmJCh5QTwp9VqN+QVV+BRxKLKmCFRuVAx+dahotxqw0=
911
exec age-keyserver -db=$WORK/test.sqlite3 -listen=localhost:13894 &srv&
1012
waitfor http://localhost:13894/
1113

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.24.0
44

55
require (
66
filippo.io/age v1.2.1
7+
filippo.io/mostly-harmless/vrf-r255 v0.0.0-20251110151915-f587ba8b0f82
78
github.com/cheggaaa/pb/v3 v3.1.5
89
github.com/rogpeppe/go-internal v1.13.1
910
github.com/transparency-dev/tessera v1.0.0
@@ -17,13 +18,15 @@ require (
1718
)
1819

1920
require (
21+
filippo.io/edwards25519 v1.1.0 // indirect
2022
github.com/VividCortex/ewma v1.2.0 // indirect
2123
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
2224
github.com/dustin/go-humanize v1.0.1 // indirect
2325
github.com/fatih/color v1.15.0 // indirect
2426
github.com/go-logr/logr v1.4.3 // indirect
2527
github.com/go-logr/stdr v1.2.2 // indirect
2628
github.com/google/uuid v1.6.0 // indirect
29+
github.com/gtank/ristretto255 v0.2.0 // indirect
2730
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
2831
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
2932
github.com/mattn/go-colorable v0.1.13 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3I
22
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
33
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
44
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
5+
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
6+
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
7+
filippo.io/mostly-harmless/vrf-r255 v0.0.0-20251110151915-f587ba8b0f82 h1:ZYps1vXve+JFo/XxLG8bCg2zIL5pn5nys5e+GnbD7nc=
8+
filippo.io/mostly-harmless/vrf-r255 v0.0.0-20251110151915-f587ba8b0f82/go.mod h1:ac5Gah0LmA0/YD4SHdO2M+WUjScWsc99zrAfJK4QViY=
59
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
610
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
711
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
@@ -23,6 +27,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
2327
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
2428
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
2529
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
30+
github.com/gtank/ristretto255 v0.2.0 h1:LeOuWr6giplWkkMizx2emfG03SRPJqKt1nfIHLVHQ/0=
31+
github.com/gtank/ristretto255 v0.2.0/go.mod h1:OJ1ox/dWcp7sJ5grYDcZ+kkHYuj5nelW5aaL7ESVXBw=
2632
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
2733
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
2834
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=

0 commit comments

Comments
 (0)