carlos / platform Public

console: keymail federation proof of work

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev b3dfbe3e807023acbe3a5257f1c0d4d4b0640ce4 parent f4ac8bd
2 files changed, +144 −0
  • internal/console/keymailpow.go +71 −0
  • internal/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)
+ }
+}