From b0ad3671897f7c8316fa8da861b3cac6261f1e0b Mon Sep 17 00:00:00 2001 From: Zach Smith Date: Thu, 30 Apr 2026 15:04:41 -0700 Subject: [PATCH 1/2] feat: add z-base-32 encoder for iroh EndpointId hex form Adds internal/iroh.EndpointHexToZ32, the encoding boundary between the hex form iroh exposes via Display (and that desktop agents write to Connector.Status.ConnectionDetails.PublicKey.Id) and the z-base-32 form iroh's DNS resolver uses to construct "_iroh.." discovery lookup names. Verified against iroh's own z32 crate test vector (z32-1.3.0/src/lib.rs public_key test): the 32-byte public key [241, 32, 213, 46, ...] encodes to "6ropkm1nz98qqwnotqz1tryk3mrfiw9u16iwzp1usci6kbqdfwho". This is the first PR in the iroh DNS discovery controller series; no behavior change yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 1 + go.sum | 2 ++ internal/iroh/z32.go | 30 +++++++++++++++++ internal/iroh/z32_test.go | 71 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 internal/iroh/z32.go create mode 100644 internal/iroh/z32_test.go diff --git a/go.mod b/go.mod index 0b3e8d9..1d71db7 100644 --- a/go.mod +++ b/go.mod @@ -236,6 +236,7 @@ require ( github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/tv42/zbase32 v0.0.0-20220222190657-f76a9fc892fa // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/vbatts/tar-split v0.12.1 // indirect github.com/vishvananda/netlink v1.3.1 // indirect diff --git a/go.sum b/go.sum index 6ce1a9b..3de6c1f 100644 --- a/go.sum +++ b/go.sum @@ -637,6 +637,8 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= +github.com/tv42/zbase32 v0.0.0-20220222190657-f76a9fc892fa h1:2EwhXkNkeMjX9iFYGWLPQLPhw9O58BhnYgtYKeqybcY= +github.com/tv42/zbase32 v0.0.0-20220222190657-f76a9fc892fa/go.mod h1:is48sjgBanWcA5CQrPBu9Y5yABY/T2awj/zI65bq704= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= diff --git a/internal/iroh/z32.go b/internal/iroh/z32.go new file mode 100644 index 0000000..d8b5e09 --- /dev/null +++ b/internal/iroh/z32.go @@ -0,0 +1,30 @@ +package iroh + +import ( + "encoding/hex" + "fmt" + + "github.com/tv42/zbase32" +) + +const endpointIDByteLen = 32 + +// EndpointHexToZ32 converts the hex form of an iroh EndpointId — as +// written to Connector.Status.ConnectionDetails.PublicKey.Id — into +// the z-base-32 form iroh's DNS resolver uses to build its +// "_iroh.." lookup names. +// +// The Connector schema carries the hex form because that's iroh's +// Display output and what iroh-base's FromStr round-trips. iroh's DNS +// layer, in contrast, always encodes the same 32 raw bytes as +// z-base-32. This function is that boundary. +func EndpointHexToZ32(hexID string) (string, error) { + b, err := hex.DecodeString(hexID) + if err != nil { + return "", fmt.Errorf("decode endpoint id hex: %w", err) + } + if len(b) != endpointIDByteLen { + return "", fmt.Errorf("endpoint id must be %d bytes, got %d", endpointIDByteLen, len(b)) + } + return zbase32.EncodeToString(b), nil +} diff --git a/internal/iroh/z32_test.go b/internal/iroh/z32_test.go new file mode 100644 index 0000000..7919eb6 --- /dev/null +++ b/internal/iroh/z32_test.go @@ -0,0 +1,71 @@ +package iroh + +import ( + "strings" + "testing" +) + +func TestEndpointHexToZ32(t *testing.T) { + tests := []struct { + name string + hexID string + want string + wantErrSubs string + }{ + { + // Cross-checked against the public_key test in iroh's z32 + // crate (z32-1.3.0/src/lib.rs): the 32-byte public key + // [241, 32, 213, 46, ...] encodes to + // "6ropkm1nz98qqwnotqz1tryk3mrfiw9u16iwzp1usci6kbqdfwho". + name: "iroh z32 crate public_key vector", + hexID: "f120d52e42bfcee750508baf28900acac85ad3f397ab4bb653b32be505c32d39", + want: "6ropkm1nz98qqwnotqz1tryk3mrfiw9u16iwzp1usci6kbqdfwho", + }, + { + name: "invalid hex characters", + hexID: "z" + strings.Repeat("0", 63), + wantErrSubs: "decode endpoint id hex", + }, + { + name: "odd-length hex", + hexID: strings.Repeat("a", 63), + wantErrSubs: "decode endpoint id hex", + }, + { + name: "too few bytes", + hexID: strings.Repeat("ab", 16), + wantErrSubs: "must be 32 bytes", + }, + { + name: "too many bytes", + hexID: strings.Repeat("ab", 64), + wantErrSubs: "must be 32 bytes", + }, + { + name: "empty", + hexID: "", + wantErrSubs: "must be 32 bytes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := EndpointHexToZ32(tt.hexID) + if tt.wantErrSubs != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil (result %q)", tt.wantErrSubs, got) + } + if !strings.Contains(err.Error(), tt.wantErrSubs) { + t.Fatalf("expected error containing %q, got %q", tt.wantErrSubs, err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("EndpointHexToZ32(%q) = %q, want %q", tt.hexID, got, tt.want) + } + }) + } +} From 6aa1d7076f68b6067e87dec6d46c4191c907163e Mon Sep 17 00:00:00 2001 From: Zach Smith Date: Thu, 30 Apr 2026 15:09:38 -0700 Subject: [PATCH 2/2] refactor: encode z-base-32 via stdlib and add upstream cross-checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the tv42/zbase32 dependency with encoding/base32 configured with the Zooko z-base-32 alphabet and no padding — the bit-packing is identical to RFC 4648 base32, so the stdlib encoder produces byte-identical output. Drops one third-party dep. TestZ32EncodingMatchesIrohUpstream runs every (bytes, z32) pair from iroh's z32 crate test table (z32-1.3.0/src/lib.rs TEST_DATA + public_key) through the new stdlib-based encoder, spanning input lengths 0–32. All ten vectors agree, confirming alphabet, bit-packing, and trailing-bit handling match the Rust implementation iroh actually uses. --- go.mod | 1 - go.sum | 2 -- internal/iroh/z32.go | 10 +++++--- internal/iroh/z32_test.go | 53 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 1d71db7..0b3e8d9 100644 --- a/go.mod +++ b/go.mod @@ -236,7 +236,6 @@ require ( github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect - github.com/tv42/zbase32 v0.0.0-20220222190657-f76a9fc892fa // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/vbatts/tar-split v0.12.1 // indirect github.com/vishvananda/netlink v1.3.1 // indirect diff --git a/go.sum b/go.sum index 3de6c1f..6ce1a9b 100644 --- a/go.sum +++ b/go.sum @@ -637,8 +637,6 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= -github.com/tv42/zbase32 v0.0.0-20220222190657-f76a9fc892fa h1:2EwhXkNkeMjX9iFYGWLPQLPhw9O58BhnYgtYKeqybcY= -github.com/tv42/zbase32 v0.0.0-20220222190657-f76a9fc892fa/go.mod h1:is48sjgBanWcA5CQrPBu9Y5yABY/T2awj/zI65bq704= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= diff --git a/internal/iroh/z32.go b/internal/iroh/z32.go index d8b5e09..d74acfc 100644 --- a/internal/iroh/z32.go +++ b/internal/iroh/z32.go @@ -1,14 +1,18 @@ package iroh import ( + "encoding/base32" "encoding/hex" "fmt" - - "github.com/tv42/zbase32" ) const endpointIDByteLen = 32 +// z32Encoding is iroh's z-base-32: RFC 4648 base32 bit-packing with the +// Zooko alphabet and no padding. iroh uses this exclusively for the +// "_iroh.." DNS discovery name. +var z32Encoding = base32.NewEncoding("ybndrfg8ejkmcpqxot1uwisza345h769").WithPadding(base32.NoPadding) + // EndpointHexToZ32 converts the hex form of an iroh EndpointId — as // written to Connector.Status.ConnectionDetails.PublicKey.Id — into // the z-base-32 form iroh's DNS resolver uses to build its @@ -26,5 +30,5 @@ func EndpointHexToZ32(hexID string) (string, error) { if len(b) != endpointIDByteLen { return "", fmt.Errorf("endpoint id must be %d bytes, got %d", endpointIDByteLen, len(b)) } - return zbase32.EncodeToString(b), nil + return z32Encoding.EncodeToString(b), nil } diff --git a/internal/iroh/z32_test.go b/internal/iroh/z32_test.go index 7919eb6..859f508 100644 --- a/internal/iroh/z32_test.go +++ b/internal/iroh/z32_test.go @@ -5,6 +5,59 @@ import ( "testing" ) +// TestZ32EncodingMatchesIrohUpstream cross-checks that our stdlib-based +// z-base-32 encoder agrees byte-for-byte with iroh's Rust z32 crate +// (z32-1.3.0/src/lib.rs) — same alphabet, bit-packing, and trailing +// padding behavior. Every vector here is lifted from that crate's own +// TEST_DATA and public_key tests; if they all pass in Go, the two +// implementations are interchangeable. +func TestZ32EncodingMatchesIrohUpstream(t *testing.T) { + tests := []struct { + name string + bytes []byte + want string + }{ + {"empty", []byte{}, ""}, + {"single zero", []byte{0}, "yy"}, + {"single 248", []byte{248}, "9y"}, + {"two bytes", []byte{100, 22}, "comy"}, + {"single 7", []byte{7}, "yh"}, + {"three bytes a", []byte{240, 191, 199}, "6n9hq"}, + {"three bytes b", []byte{212, 122, 4}, "4t7ye"}, + { + "ten bytes", + []byte{4, 17, 130, 50, 156, 17, 148, 233, 91, 94}, + "yoearcwhngkq1s46", + }, + { + "alphabet round-trip", + []byte{ + 0, 68, 50, 20, 199, 66, 84, 182, 53, 207, + 132, 101, 58, 86, 215, 198, 117, 190, 119, 223, + }, + "ybndrfg8ejkmcpqxot1uwisza345h769", + }, + { + // 32-byte public-key test vector from z32-1.3.0/src/lib.rs. + "32-byte public key", + []byte{ + 241, 32, 213, 46, 66, 191, 206, 231, 80, 80, 139, 175, 40, 144, + 10, 202, 200, 90, 211, 243, 151, 171, 75, 182, 83, 179, 43, 229, + 5, 195, 45, 57, + }, + "6ropkm1nz98qqwnotqz1tryk3mrfiw9u16iwzp1usci6kbqdfwho", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := z32Encoding.EncodeToString(tt.bytes); got != tt.want { + t.Fatalf("z32Encoding diverged from iroh upstream: got %q, want %q", got, tt.want) + } + }) + } +} + func TestEndpointHexToZ32(t *testing.T) { tests := []struct { name string