Skip to content

Commit ef91243

Browse files
committed
cmd/age-keyserver: add transparency log of stored keys
1 parent 1893850 commit ef91243

8 files changed

Lines changed: 285 additions & 5 deletions

File tree

cmd/age-keylookup/main.go

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,40 @@
11
package main
22

33
import (
4+
"context"
45
"encoding/json"
56
"flag"
67
"fmt"
78
"io"
89
"net/http"
910
"net/url"
1011
"os"
12+
"strings"
1113
"time"
14+
15+
"filippo.io/torchwood"
16+
"golang.org/x/mod/sumdb/note"
17+
"golang.org/x/mod/sumdb/tlog"
1218
)
1319

1420
func main() {
21+
allFlag := flag.Bool("all", false, "list all public keys in the transparency log")
1522
flag.Parse()
1623

1724
if flag.NArg() != 1 {
18-
fmt.Fprintf(os.Stderr, "Usage: age-keylookup <email>\n")
25+
fmt.Fprintf(os.Stderr, "Usage: age-keylookup [-all] <email>\n")
1926
fmt.Fprintf(os.Stderr, "\n")
2027
fmt.Fprintf(os.Stderr, "Look up an age public key by email address.\n")
2128
fmt.Fprintf(os.Stderr, "\n")
29+
fmt.Fprintf(os.Stderr, "With -all, it enumerates all public keys in the transparency log.\n")
30+
fmt.Fprintf(os.Stderr, "\n")
2231
fmt.Fprintf(os.Stderr, "Example:\n")
2332
fmt.Fprintf(os.Stderr, " age-keylookup filippo@example.com\n")
2433
fmt.Fprintf(os.Stderr, " age -r $(age-keylookup filippo@example.com) -o secret.txt.age secret.txt\n")
2534
fmt.Fprintf(os.Stderr, "\n")
2635
fmt.Fprintf(os.Stderr, "Environment:\n")
2736
fmt.Fprintf(os.Stderr, " AGE_KEYSERVER_URL Default keyserver URL\n")
37+
fmt.Fprintf(os.Stderr, " AGE_KEYSERVER_PUBKEY Default keyserver transparency log vkey\n")
2838
os.Exit(2)
2939
}
3040

@@ -36,7 +46,32 @@ func main() {
3646
server = "https://keyserver.geomys.org"
3747
}
3848

39-
pubkey, err := lookupKey(server, email)
49+
vkey := os.Getenv("AGE_KEYSERVER_PUBKEY")
50+
if vkey == "" {
51+
vkey = "keyserver.geomys.org+be45b77c+Ae58So8awYbwaF+V98htpY0xXlRcjhNuL5Ucrq9en2yp"
52+
}
53+
v, err := note.NewVerifier(vkey)
54+
if err != nil {
55+
fmt.Fprintf(os.Stderr, "Error: invalid keyserver public key: %v\n", err)
56+
os.Exit(1)
57+
}
58+
59+
// Normalize email
60+
email = strings.TrimSpace(strings.ToLower(email))
61+
62+
if *allFlag {
63+
pubkeys, err := monitorLog(server, v, email)
64+
if err != nil {
65+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
66+
os.Exit(1)
67+
}
68+
for _, pk := range pubkeys {
69+
fmt.Println(pk)
70+
}
71+
return
72+
}
73+
74+
pubkey, err := lookupKey(server, v, email)
4075
if err != nil {
4176
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
4277
os.Exit(1)
@@ -45,7 +80,7 @@ func main() {
4580
fmt.Println(pubkey)
4681
}
4782

48-
func lookupKey(serverURL, email string) (string, error) {
83+
func lookupKey(serverURL string, v note.Verifier, email string) (string, error) {
4984
// Build the lookup URL
5085
lookupURL := serverURL + "/api/lookup?email=" + url.QueryEscape(email)
5186

@@ -75,6 +110,7 @@ func lookupKey(serverURL, email string) (string, error) {
75110
var result struct {
76111
Email string `json:"email"`
77112
Pubkey string `json:"pubkey"`
113+
Proof string `json:"proof"`
78114
}
79115

80116
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -88,5 +124,59 @@ func lookupKey(serverURL, email string) (string, error) {
88124
return "", fmt.Errorf("empty public key returned")
89125
}
90126

127+
// Verify spicy signature
128+
entry := fmt.Appendf(nil, "%s\n%s\n", result.Email, result.Pubkey)
129+
if err := torchwood.VerifyProof(v.Name(), func(b []byte) (*note.Note, error) {
130+
return note.Open(b, note.VerifierList(v))
131+
}, tlog.RecordHash(entry), []byte(result.Proof)); err != nil {
132+
return "", fmt.Errorf("failed to verify key proof: %w", err)
133+
}
134+
91135
return result.Pubkey, nil
92136
}
137+
138+
func monitorLog(serverURL string, v note.Verifier, email string) ([]string, error) {
139+
f, err := torchwood.NewTileFetcher(serverURL+"/tlog", torchwood.WithUserAgent("age-keylookup/1.0"))
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to create tile fetcher: %w", err)
142+
}
143+
c, err := torchwood.NewClient(f)
144+
if err != nil {
145+
return nil, fmt.Errorf("failed to create torchwood client: %w", err)
146+
}
147+
148+
// Fetch and verify checkpoint
149+
signedCheckpoint, err := f.ReadEndpoint(context.Background(), "checkpoint")
150+
if err != nil {
151+
return nil, fmt.Errorf("failed to read checkpoint: %w", err)
152+
}
153+
n, err := note.Open(signedCheckpoint, note.VerifierList(v))
154+
if err != nil {
155+
return nil, fmt.Errorf("failed to verify checkpoint: %w", err)
156+
}
157+
checkpoint, err := torchwood.ParseCheckpoint(n.Text)
158+
if err != nil {
159+
return nil, fmt.Errorf("failed to parse checkpoint: %w", err)
160+
}
161+
162+
// Fetch all entries up to the checkpoint size
163+
var pubkeys []string
164+
for i, entry := range c.AllEntries(context.Background(), checkpoint.Tree, 0) {
165+
e, rest, ok := strings.Cut(string(entry), "\n")
166+
if !ok {
167+
return nil, fmt.Errorf("malformed log entry %d: %q", i, string(entry))
168+
}
169+
k, rest, ok := strings.Cut(rest, "\n")
170+
if !ok || rest != "" {
171+
return nil, fmt.Errorf("malformed log entry %d: %q", i, string(entry))
172+
}
173+
if e == email {
174+
pubkeys = append(pubkeys, k)
175+
}
176+
}
177+
if c.Err() != nil {
178+
return nil, fmt.Errorf("error fetching log entries: %w", c.Err())
179+
}
180+
181+
return pubkeys, nil
182+
}

cmd/age-keyserver-keygen/keygen.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package main
2+
3+
import (
4+
"crypto/rand"
5+
"fmt"
6+
"os"
7+
8+
"golang.org/x/mod/sumdb/note"
9+
)
10+
11+
func main() {
12+
if len(os.Args) != 2 {
13+
fmt.Fprintf(os.Stderr, "Usage: %s <origin>\n", os.Args[0])
14+
os.Exit(1)
15+
}
16+
origin := os.Args[1]
17+
18+
skey, vkey, err := note.GenerateKey(rand.Reader, origin)
19+
if err != nil {
20+
fmt.Fprintf(os.Stderr, "Error generating keys: %v\n", err)
21+
os.Exit(1)
22+
}
23+
24+
fmt.Printf("Private key (for LOG_KEY in age-keyserver): %s\n", skey)
25+
fmt.Printf("Public key (for AGE_KEYSERVER_PUBKEY in age-keylookup): %s\n", vkey)
26+
}

cmd/age-keyserver/main.go

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

2626
"filippo.io/age"
27+
"filippo.io/torchwood"
28+
"filippo.io/torchwood/tesserax"
29+
"github.com/transparency-dev/tessera"
30+
"github.com/transparency-dev/tessera/storage/posix"
31+
"golang.org/x/mod/sumdb/note"
32+
"golang.org/x/mod/sumdb/tlog"
2733
"golang.org/x/net/http2"
2834
"golang.org/x/net/http2/h2c"
2935
"zombiezen.com/go/sqlite"
@@ -35,6 +41,7 @@ var (
3541
embeddedFS embed.FS
3642

3743
dbPath = flag.String("db", "keyserver.sqlite3", "path to SQLite database")
44+
logPath = flag.String("logdir", "keyserver-tlog", "directory for transparency log")
3845
listenAddr = flag.String("listen", "localhost:13889", "address to listen on")
3946
)
4047

@@ -43,11 +50,16 @@ type Server struct {
4350
templates *template.Template
4451
hmacKey []byte
4552
baseURL string
53+
reader tessera.LogReader
54+
appender *tessera.Appender
55+
awaiter *tessera.PublicationAwaiter
56+
verifier note.Verifier
4657
}
4758

4859
type KeyData struct {
4960
Pubkey string `json:"pubkey"`
5061
UpdatedAt int64 `json:"updated_at"`
62+
LogIndex int64 `json:"log_index"`
5163
}
5264

5365
const (
@@ -61,6 +73,33 @@ const (
6173

6274
func main() {
6375
flag.Parse()
76+
ctx := context.Background()
77+
78+
s, err := note.NewSigner(os.Getenv("LOG_KEY"))
79+
if err != nil {
80+
log.Fatalln("failed to create checkpoint signer:", err)
81+
}
82+
verifier, err := torchwood.NewVerifierFromSigner(os.Getenv("LOG_KEY"))
83+
if err != nil {
84+
log.Fatalln("failed to create checkpoint verifier:", err)
85+
}
86+
87+
driver, err := posix.New(ctx, posix.Config{
88+
Path: *logPath,
89+
})
90+
if err != nil {
91+
log.Fatalln("failed to create log storage driver:", err)
92+
}
93+
94+
appender, shutdown, logReader, err := tessera.NewAppender(ctx, driver, tessera.NewAppendOptions().
95+
WithCheckpointSigner(s).
96+
WithBatching(256, 250*time.Millisecond).
97+
WithCheckpointInterval(1*time.Second))
98+
if err != nil {
99+
log.Fatalln("failed to create log appender:", err)
100+
}
101+
defer shutdown(context.Background())
102+
awaiter := tessera.NewPublicationAwaiter(ctx, logReader.ReadCheckpoint, 250*time.Millisecond)
64103

65104
// Check for development vs production mode
66105
postmarkToken := os.Getenv("POSTMARK_TOKEN")
@@ -111,6 +150,10 @@ func main() {
111150
templates: templates,
112151
hmacKey: hmacKey,
113152
baseURL: baseURL,
153+
reader: logReader,
154+
appender: appender,
155+
awaiter: awaiter,
156+
verifier: verifier,
114157
}
115158

116159
// Set up routes
@@ -129,6 +172,10 @@ func main() {
129172
}
130173
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
131174

175+
// Serve tlog-tiles log
176+
fs := http.StripPrefix("/tlog/", http.FileServer(http.Dir(*logPath)))
177+
mux.Handle("GET /tlog/", fs)
178+
132179
// Start server with h2c support
133180
log.Println("")
134181
log.Printf("Starting age Keyserver on %s", *listenAddr)
@@ -183,6 +230,14 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
183230
http.Error(w, "Email is required", http.StatusBadRequest)
184231
return
185232
}
233+
// Emails are technically case sensitive, but users are unlikely to monitor
234+
// all case variations, so we normalize to lowercase. We do ti before
235+
// sending the logic link, so normalization can't lead to impersonation.
236+
email = strings.ToLower(email)
237+
if strings.ContainsAny(email, "\n") {
238+
http.Error(w, "Invalid email format", http.StatusBadRequest)
239+
return
240+
}
186241

187242
// Verify captcha
188243
if !verifyCaptcha(captchaResponse) {
@@ -276,18 +331,37 @@ func (s *Server) handleSetKey(w http.ResponseWriter, r *http.Request) {
276331
}
277332

278333
// Validate age public key
334+
var proof string
279335
if pubkey != "" {
280336
if _, err := age.ParseX25519Recipient(pubkey); err != nil {
281337
http.Error(w, "Invalid age public key format", http.StatusBadRequest)
282338
return
283339
}
284340

341+
// Add to transparency log
342+
entry := tessera.NewEntry(fmt.Appendf(nil, "%s\n%s\n", email, pubkey))
343+
index, _, err := s.awaiter.Await(r.Context(), s.appender.Add(r.Context(), entry))
344+
if err != nil {
345+
http.Error(w, "Failed to add to transparency log", http.StatusInternalServerError)
346+
log.Printf("transparency log error: %v", err)
347+
return
348+
}
349+
285350
// Store in database
286-
if err := s.storeKey(email, pubkey); err != nil {
351+
if err := s.storeKey(email, pubkey, int64(index.Index)); err != nil {
287352
http.Error(w, "Failed to store key", http.StatusInternalServerError)
288353
log.Printf("database error: %v", err)
289354
return
290355
}
356+
357+
// Generate proof for success page
358+
proofBytes, err := s.makeSpicySignature(r.Context(), int64(index.Index))
359+
if err != nil {
360+
http.Error(w, "Failed to create proof", http.StatusInternalServerError)
361+
log.Printf("proof error: %v", err)
362+
return
363+
}
364+
proof = string(proofBytes)
291365
} else {
292366
// Delete key
293367
if err := s.deleteKey(email); err != nil {
@@ -301,6 +375,7 @@ func (s *Server) handleSetKey(w http.ResponseWriter, r *http.Request) {
301375
if err := s.templates.ExecuteTemplate(w, "success.html", map[string]string{
302376
"Email": email,
303377
"Pubkey": pubkey,
378+
"Proof": proof,
304379
}); err != nil {
305380
http.Error(w, "Internal server error", http.StatusInternalServerError)
306381
log.Printf("template error: %v", err)
@@ -325,14 +400,43 @@ func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) {
325400
return
326401
}
327402

403+
proof, err := s.makeSpicySignature(r.Context(), data.LogIndex)
404+
if err != nil {
405+
http.Error(w, "Failed to create proof", http.StatusInternalServerError)
406+
log.Printf("proof error: %v", err)
407+
return
408+
}
409+
328410
// Return as JSON
329411
w.Header().Set("Content-Type", "application/json")
330412
json.NewEncoder(w).Encode(map[string]string{
331413
"email": email,
332414
"pubkey": data.Pubkey,
415+
"proof": string(proof),
333416
})
334417
}
335418

419+
func (s *Server) makeSpicySignature(ctx context.Context, index int64) ([]byte, error) {
420+
checkpoint, err := s.reader.ReadCheckpoint(ctx)
421+
if err != nil {
422+
return nil, fmt.Errorf("failed to read checkpoint: %v", err)
423+
}
424+
n, err := note.Open(checkpoint, note.VerifierList(s.verifier))
425+
if err != nil {
426+
return nil, fmt.Errorf("failed to open checkpoint note: %v", err)
427+
}
428+
c, err := torchwood.ParseCheckpoint(n.Text)
429+
if err != nil {
430+
return nil, fmt.Errorf("failed to parse checkpoint: %v", err)
431+
}
432+
p, err := tlog.ProveRecord(c.N, index, torchwood.TileHashReaderWithContext(
433+
ctx, c.Tree, tesserax.NewTileReader(s.reader)))
434+
if err != nil {
435+
return nil, fmt.Errorf("failed to create proof: %v", err)
436+
}
437+
return torchwood.FormatProof(index, p, checkpoint), nil
438+
}
439+
336440
func (s *Server) generateHMAC(email string, ts int64) string {
337441
msg := fmt.Sprintf("%s:%d", email, ts)
338442
h := hmac.New(sha256.New, s.hmacKey)
@@ -403,10 +507,11 @@ func (s *Server) getKeyData(email string) (*KeyData, error) {
403507
return &data, nil
404508
}
405509

406-
func (s *Server) storeKey(email, pubkey string) error {
510+
func (s *Server) storeKey(email, pubkey string, index int64) error {
407511
data := KeyData{
408512
Pubkey: pubkey,
409513
UpdatedAt: time.Now().Unix(),
514+
LogIndex: index,
410515
}
411516

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

0 commit comments

Comments
 (0)