console: keymail federation proof of work
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2 files changed,
+144
−0
internal/console/keymailpow.go+71 −0internal/console/keymailpow_test.go+73 −0
diff --git a/internal/console/keymailpow.go b/internal/console/keymailpow.go| new file mode 100644 |
| index 000000000..a11f023bb |
| --- /dev/null |
| +++ b/internal/console/keymailpow.go |
| @@ -0,0 +1,71 @@ |
| +package console |
| + |
| +// Proof of work for Keymail federation, ported from keymail |
| +// internal/pow/pow.go and the nonce rule in federation.go handleRegister. |
| +// An unknown sender pays it once per recipient mailbox to register its |
| +// signing key; after that, deliveries from the same key are free. |
| +// |
| +// nonce = SHA-256(challenge token) |
| +// find counter: SHA-256(nonce || sign_pub_raw || counter_be64) has >= bits leading zero bits |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/binary" |
| + "math/bits" |
| +) |
| + |
| +// maxFederationPowBits is the highest price the console will pay. A mailbox |
| +// sets its own price, and an invite runs inside an HTTP request: 24 bits is |
| +// about sixteen million hashes, a few seconds on one core. Keymail's default |
| +// is 20. |
| +const maxFederationPowBits = 24 |
| + |
| +func powLeadingZeroBits(h []byte) int { |
| + n := 0 |
| + for _, b := range h { |
| + if b == 0 { |
| + n += 8 |
| + continue |
| + } |
| + return n + bits.LeadingZeros8(b) |
| + } |
| + return n |
| +} |
| + |
| +func powVerify(nonce, signPub []byte, counter uint64, difficulty int) bool { |
| + if difficulty <= 0 { |
| + return true |
| + } |
| + h := sha256.New() |
| + h.Write(nonce) |
| + h.Write(signPub) |
| + var c [8]byte |
| + binary.BigEndian.PutUint64(c[:], counter) |
| + h.Write(c[:]) |
| + return powLeadingZeroBits(h.Sum(nil)) >= difficulty |
| +} |
| + |
| +// powMine searches counters from 0 on one goroutine, checking ctx every |
| +// 65536 attempts. One core is enough at the prices the console accepts. |
| +func powMine(ctx context.Context, nonce, signPub []byte, difficulty int) (uint64, error) { |
| + if difficulty <= 0 { |
| + return 0, nil |
| + } |
| + prefix := make([]byte, 0, len(nonce)+len(signPub)+8) |
| + prefix = append(append(prefix, nonce...), signPub...) |
| + buf := append(prefix, make([]byte, 8)...) |
| + n := len(prefix) |
| + for counter := uint64(0); ; counter++ { |
| + if counter&0xffff == 0 { |
| + if err := ctx.Err(); err != nil { |
| + return 0, err |
| + } |
| + } |
| + binary.BigEndian.PutUint64(buf[n:], counter) |
| + sum := sha256.Sum256(buf) |
| + if powLeadingZeroBits(sum[:]) >= difficulty { |
| + return counter, nil |
| + } |
| + } |
| +} |
diff --git a/internal/console/keymailpow_test.go b/internal/console/keymailpow_test.go| new file mode 100644 |
| index 000000000..66e7f3cc6 |
| --- /dev/null |
| +++ b/internal/console/keymailpow_test.go |
| @@ -0,0 +1,73 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/binary" |
| + "errors" |
| + "math/bits" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// refLeadingZeros is written out independently of keymailpow.go so the |
| +// test does not check the code against itself. |
| +func refLeadingZeros(h []byte) int { |
| + n := 0 |
| + for _, b := range h { |
| + if b == 0 { |
| + n += 8 |
| + continue |
| + } |
| + return n + bits.LeadingZeros8(b) |
| + } |
| + return n |
| +} |
| + |
| +func refHash(nonce, signPub []byte, counter uint64) []byte { |
| + h := sha256.New() |
| + h.Write(nonce) |
| + h.Write(signPub) |
| + var c [8]byte |
| + binary.BigEndian.PutUint64(c[:], counter) |
| + h.Write(c[:]) |
| + return h.Sum(nil) |
| +} |
| + |
| +func TestPowMineFindsAVerifiableCounter(t *testing.T) { |
| + nonce := sha256.Sum256([]byte("challenge-token")) |
| + signPub := []byte("sign-pub-bytes") |
| + ctr, err := powMine(context.Background(), nonce[:], signPub, 12) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := refLeadingZeros(refHash(nonce[:], signPub, ctr)); got < 12 { |
| + t.Fatalf("counter %d gives %d leading zero bits, want >= 12", ctr, got) |
| + } |
| + if !powVerify(nonce[:], signPub, ctr, 12) { |
| + t.Fatal("powVerify rejects the counter powMine found") |
| + } |
| + if powVerify(nonce[:], []byte("another key"), ctr, 12) && refLeadingZeros(refHash(nonce[:], []byte("another key"), ctr)) < 12 { |
| + t.Fatal("powVerify accepted a solution for a different key") |
| + } |
| +} |
| + |
| +func TestPowZeroBitsIsFree(t *testing.T) { |
| + if !powVerify(nil, nil, 0, 0) { |
| + t.Fatal("0 bits must verify") |
| + } |
| + ctr, err := powMine(context.Background(), nil, nil, 0) |
| + if err != nil || ctr != 0 { |
| + t.Fatalf("powMine(0 bits) = %d, %v; want 0, nil", ctr, err) |
| + } |
| +} |
| + |
| +func TestPowMineHonoursCancellation(t *testing.T) { |
| + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) |
| + defer cancel() |
| + // 64 bits will never be found. |
| + _, err := powMine(ctx, []byte("n"), []byte("k"), 64) |
| + if !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("powMine under a 20ms deadline = %v, want DeadlineExceeded", err) |
| + } |
| +} |