Skip to content

Latest commit

 

History

History
95 lines (67 loc) · 5.02 KB

File metadata and controls

95 lines (67 loc) · 5.02 KB

Lessons Learned

Technical findings from development of the mipzda I2P Gemini portal app.

go-i2p Embedded Router: NTCP2 Bug

Status: Known bug in github.com/go-i2p/go-i2p v0.1.3. Every outbound NTCP2 connection attempt fails with:

crypto/ecdh: invalid public key

Root cause (likely): The X25519 static public key in NTCP2's s= RouterInfo option is a raw 32-byte value encoded in I2P's base64 variant (- and ~ instead of + and /, no padding). go-i2p appears to misparse it — either using standard base64, mishandling padding, or passing wrong-length bytes to crypto/ecdh.X25519().NewPublicKey() (which requires exactly 32 bytes in raw scalar form, not the 33-byte compressed point format used by P-256/P-384).

Impact: The embedded router cannot make any outbound connections and is therefore non-functional for routing Gemini traffic.

Workaround: Connect mipzda to an external i2pd instance via SAM (port 7656). The app already supports this via the SAM address setting. This is the currently viable path.

Future fix options:

  • File a bug on go-i2p pointing to the NTCP2 static key parsing in go-i2p/lib/transport/ntcp2.
  • Patch locally with a replace directive in go.mod and fix the key decoding.

What won't help: The cmd/sam-bridge CLI from go-sam-bridge connects to an existing I2P router via I2CP — it does not start the embedded go-i2p router and will not reproduce the NTCP2 failure.

go-i2p Embedded Router: Reseeding / NetDB Bootstrap

The embedded router needs a populated NetDB to bootstrap. go-i2p's composite bootstrap order is: local zip/su3 file → reseed servers → local netDb directory.

Fix: Bundle a netDb.zip snapshot directly in the binary via go:embed. On first run, write it to ~/.go-i2p/netDb.zip and set config.DefaultBootstrapConfig.ReseedFilePath to that path. Also add ~/.go-i2p/config/netDb to LocalNetDbPaths so the router's own accumulated NetDB is found on subsequent starts.

//go:embed netDb.zip
var bundledNetDb []byte

// In main(), before starting the embedded router:
homeDir, _ := os.UserHomeDir()
goI2PDir := filepath.Join(homeDir, ".go-i2p")
os.MkdirAll(goI2PDir, 0755)
netDbZipPath := filepath.Join(goI2PDir, "netDb.zip")
if _, err := os.Stat(netDbZipPath); os.IsNotExist(err) {
    os.WriteFile(netDbZipPath, bundledNetDb, 0644)
}
config.DefaultBootstrapConfig.ReseedFilePath = netDbZipPath
savedNetDbPath := filepath.Join(goI2PDir, "config", "netDb")
config.DefaultBootstrapConfig.LocalNetDbPaths = []string{savedNetDbPath}

I2P Through NAT and Firewalls

I2P works through NAT and firewalls that permit outbound TCP/UDP traffic. No inbound port forwarding is required for a client — all connections are initiated outbound. (Inbound reachability improves your router's ability to participate as a relay, but is not needed for client-only use.)

mipzda-server: TLS Is Not Doubled

gemini.Server.Serve() is a bare accept-and-handle loop. It does not apply TLS itself. TLS is applied by wrapping the I2P net.Listener with tls.NewListener before passing it to Serve. There is no double-TLS.

gemini.Server.GetCertificate is only used by ListenAndServe (which creates its own TLS listener internally). It has no effect when calling Serve directly with a pre-wrapped listener and should not be set.

go-gemini: TOFU Without a TLS Hook

github.com/makeworld-the-better-one/go-gemini v0.13.1 hardcodes InsecureSkipVerify: true internally in Client.connect(). There is no TLSConfig field and no hook for custom certificate verification during the handshake.

TOFU implementation: After a successful fetch, read resp.Cert (a *x509.Certificate), compute its SHA-256 fingerprint, and compare against the value stored in Fyne app preferences under key tofu_<host>. Store the fingerprint on first visit; reject on mismatch on subsequent visits.

func checkTOFU(myApp fyne.App, host string, cert *x509.Certificate) error {
    if cert == nil {
        return nil
    }
    fingerprint := hex.EncodeToString(sha256.New().Sum(cert.Raw))
    key := "tofu_" + host
    prefs := myApp.Preferences()
    stored := prefs.String(key)
    if stored == "" {
        prefs.SetString(key, fingerprint)
        return nil
    }
    if stored != fingerprint {
        return fmt.Errorf("TOFU: certificate fingerprint mismatch for %s", host)
    }
    return nil
}

SAM Session Lifecycle

Creating a new SAM session per fetch is fragile and slow. Use a persistent session, lazily initialized, with a mutex-protected reset on failure:

  • samConn *sam3.SAM and samStream *sam3.StreamSession as package-level vars under samMu sync.Mutex
  • ensureSession() returns early if already connected
  • On DialI2P failure in the fetch proxy, nil both vars under the lock so the next call re-creates them
  • Never call sam.Close() or stream.Close() inside the fetch path; only close on app exit

Fyne: Settings Dialog Width

dialog.NewForm ignores the size parameter in its constructor. To set a minimum width, call d.Resize(fyne.NewSize(400, 0)) before d.Show().