diff --git a/cmd/rendezvous/main.go b/cmd/rendezvous/main.go index 2bf4adf..1bf063e 100644 --- a/cmd/rendezvous/main.go +++ b/cmd/rendezvous/main.go @@ -67,6 +67,7 @@ func main() { tlsKey := flag.String("tls-key", "", "TLS key file") enableTLS := flag.Bool("tls", false, "enable TLS for registry connections") strictDirectoryAuth := flag.Bool("strict-directory-auth", false, "WS2: require trust/shared-network authorization on directory RPCs (lookup/resolve/punch/list_*/check_trust). Default false (not enforcing, wire-compatible with old agents). Env: RENDEZVOUS_STRICT_DIRECTORY_AUTH=1.") + strictRegistrationAuth := flag.Bool("strict-registration-auth", false, "require a valid proof-of-possession signature on registrations that submit a public_key. Default false (signatures verified when present, but unsigned registrations from old agents are still accepted). Env: RENDEZVOUS_STRICT_REGISTRATION_AUTH=1.") standbyPrimary := flag.String("standby", "", "run as hot standby replicating from the given primary address (e.g. primary:9000)") httpAddr := flag.String("http", "", "HTTP dashboard listen address (e.g. :3000)") logLevel := flag.String("log-level", "info", "log level (debug, info, warn, error)") @@ -128,6 +129,10 @@ func main() { r.SetStrictDirectoryAuth(true) slog.Info("strict directory authorization enabled (WS2)") } + if *strictRegistrationAuth || os.Getenv("RENDEZVOUS_STRICT_REGISTRATION_AUTH") == "1" { + r.SetStrictRegistrationAuth(true) + slog.Info("strict registration authorization enabled") + } // Plumb the breaker manager into the in-process beacon so // beacon.punch / beacon.relay / beacon.discover can be flipped from // the same breakers.json file as the registry-side switches. diff --git a/directory/directory.go b/directory/directory.go index 1dc5b68..0d014f4 100644 --- a/directory/directory.go +++ b/directory/directory.go @@ -10,6 +10,7 @@ package directory import ( + "encoding/base64" "encoding/json" "fmt" "log/slog" @@ -236,6 +237,7 @@ type Callbacks struct { // a reaped node reclaims its old identity. Caller holds mu.Lock. ScanNetworkMemberships func(nodeID uint32) []uint16 StrictDirectoryAuth func() bool + StrictRegistrationAuth func() bool } // -------------------------------------------------------------------------- @@ -720,6 +722,24 @@ func (st *Store) HandleRegister( return nil, fmt.Errorf("registration requires public_key") } + sigB64, _ := msg["signature"].(string) + strictReg := st.cb.StrictRegistrationAuth != nil && st.cb.StrictRegistrationAuth() + if sigB64 != "" { + pubKey, err := crypto.DecodePublicKey(pubKeyB64) + if err != nil { + return nil, fmt.Errorf("registration: invalid public_key") + } + sig, err := base64.StdEncoding.DecodeString(sigB64) + if err != nil { + return nil, fmt.Errorf("registration: invalid signature encoding") + } + if !crypto.Verify(pubKey, []byte(fmt.Sprintf("register:%s:%s", clientAddr, pubKeyB64)), sig) { + return nil, fmt.Errorf("registration: signature verification failed") + } + } else if strictReg { + return nil, fmt.Errorf("registration: signature required") + } + if hostname != "" { if err := ValidateHostname(hostname); err != nil { resp, regErr := st.HandleReRegister(pubKeyB64, listenAddr, owner, "", lanAddrs, clientVersion, relayOnly) diff --git a/directory/zz_ppa003_registration_signature_test.go b/directory/zz_ppa003_registration_signature_test.go new file mode 100644 index 0000000..6e5b36a --- /dev/null +++ b/directory/zz_ppa003_registration_signature_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package directory + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "testing" + + "github.com/pilot-protocol/common/crypto" +) + +func TestHandleRegisterSignatureVerification(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + pubB64 := crypto.EncodePublicKey(pub) + const listenAddr = "10.0.0.1:4000" + + sign := func(p ed25519.PrivateKey) string { + return base64.StdEncoding.EncodeToString( + ed25519.Sign(p, []byte(fmt.Sprintf("register:%s:%s", listenAddr, pubB64)))) + } + goodSig := sign(priv) + + _, otherPriv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + wrongKeySig := sign(otherPriv) + + regMsg := func(sig string) map[string]interface{} { + m := map[string]interface{}{ + "public_key": pubB64, + "listen_addr": listenAddr, + "owner": "alice", + } + if sig != "" { + m["signature"] = sig + } + return m + } + + cases := []struct { + name string + strict bool + sig string + wantErr bool + }{ + {"unsigned accepted by default (backward compatible)", false, "", false}, + {"unsigned rejected when strict", true, "", true}, + {"valid signature accepted (lenient)", false, goodSig, false}, + {"valid signature accepted (strict)", true, goodSig, false}, + {"wrong-key signature rejected (lenient verify-if-present)", false, wrongKeySig, true}, + {"wrong-key signature rejected (strict)", true, wrongKeySig, true}, + {"malformed signature rejected", false, "not-base64!!", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := newTestStore(t) + st.cb.StrictRegistrationAuth = func() bool { return tc.strict } + resp, err := st.HandleRegister(regMsg(tc.sig), listenAddr, nil, nil) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got resp=%v", resp) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp["type"] != "register_ok" { + t.Fatalf("expected register_ok, got %v", resp) + } + }) + } +} diff --git a/server.go b/server.go index 65eec06..969f5d3 100644 --- a/server.go +++ b/server.go @@ -89,13 +89,13 @@ type Server struct { // its own internal locking. Surfaced read-only via /api/health and // included in the rich /api/stats payload. Reads are non-blocking // (atomic loads) so a probe doesn't contend with a save in flight. - snapshotsTotal atomic.Int64 // successful flushSave calls - snapshotsFailed atomic.Int64 // flushSave returning an error - lastSnapshotUnixMs atomic.Int64 // wall time of last successful save - lastSnapshotDurMs atomic.Int64 // duration of last successful save - lastSnapshotSizeB atomic.Int64 // bytes written by last successful save - lastSnapshotRLockMs atomic.Int64 // s.mu.RLock hold time in phase 1 - maxSnapshotDurMs atomic.Int64 // worst-ever save duration + snapshotsTotal atomic.Int64 // successful flushSave calls + snapshotsFailed atomic.Int64 // flushSave returning an error + lastSnapshotUnixMs atomic.Int64 // wall time of last successful save + lastSnapshotDurMs atomic.Int64 // duration of last successful save + lastSnapshotSizeB atomic.Int64 // bytes written by last successful save + lastSnapshotRLockMs atomic.Int64 // s.mu.RLock hold time in phase 1 + maxSnapshotDurMs atomic.Int64 // worst-ever save duration // buildInfo carries the build-time identity surfaced on // /api/public-stats for code-verification (version, git commit, ISO @@ -284,7 +284,8 @@ type Server struct { listNodesPerNetMu sync.Mutex // guards the map itself listNodesPerNet map[uint16]*listNodesCacheState - strictDirectoryAuth atomic.Bool + strictDirectoryAuth atomic.Bool + strictRegistrationAuth atomic.Bool } // listNodesCacheState is defined in the directory sub-package (R4.2). diff --git a/server_lifecycle.go b/server_lifecycle.go index 23b23b5..4fde4d2 100644 --- a/server_lifecycle.go +++ b/server_lifecycle.go @@ -125,6 +125,14 @@ func (s *Server) StrictDirectoryAuth() bool { return s.strictDirectoryAuth.Load() } +func (s *Server) SetStrictRegistrationAuth(enabled bool) { + s.strictRegistrationAuth.Store(enabled) +} + +func (s *Server) StrictRegistrationAuth() bool { + return s.strictRegistrationAuth.Load() +} + // SetDashboardToken gates per-network stats on the dashboard. // Empty string restricts the dashboard to global aggregates only. func (s *Server) SetDashboardToken(token string) { @@ -941,7 +949,8 @@ func NewWithStore(beaconAddr, storePath string) *Server { } return nets }, - StrictDirectoryAuth: s.StrictDirectoryAuth, + StrictDirectoryAuth: s.StrictDirectoryAuth, + StrictRegistrationAuth: s.StrictRegistrationAuth, }, ) @@ -1105,10 +1114,10 @@ func NewWithStore(beaconAddr, storePath string) *Server { allow, _ := s.breakers.Allow(name) return allow, s.breakers.Reason(name) }, - BreakerList: s.BreakerList, - BreakerSet: s.BreakerSet, - BreakerDelete: s.BreakerDelete, - HealthSnapshot: s.HealthSnapshot, + BreakerList: s.BreakerList, + BreakerSet: s.BreakerSet, + BreakerDelete: s.BreakerDelete, + HealthSnapshot: s.HealthSnapshot, VerifyRequest: func(canonical, sigB64 string) interface{} { return s.VerifyRequest(canonical, sigB64) },