Skip to content

Commit efbd5fb

Browse files
TeoSlayerteovlclaude
authored
security: sign registry registration — proof-of-possession (PPA-003)
RegisterWithKeyOpts (the single builder emitting a public_key; RegisterWithKey funnels through it) now attaches a signature over "register:<listenAddr>:<publicKeyB64>", proving the submitter holds the private key for the registered public_key — matching the existing signed heartbeat/deregister/resolve operations. The challenge binds the public_key (defeats key-directory poisoning) and the listen_addr (hardens the re-registration endpoint-repoint vector), and is verifiable by the server before node_id assignment (node_id is not an input). Additive and backward-compatible: only an optional signature field is added, best-effort when a signer is configured; keyless/anonymous registration is unchanged. Server-side enforcement and registry-TLS-by-default are separate coordinated changes. Co-authored-by: Teodor Calin <[email protected]> Co-authored-by: Claude Opus 4.8 <[email protected]>
1 parent 3d72810 commit efbd5fb

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

registry/client/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,11 @@ func (c *Client) RegisterWithKeyOpts(o RegisterOpts) (map[string]interface{}, er
596596
if o.RelayOnly {
597597
msg["relay_only"] = true
598598
}
599+
if o.PublicKey != "" {
600+
if sig, err := c.sign(fmt.Sprintf("register:%s:%s", o.ListenAddr, o.PublicKey)); err == nil {
601+
msg["signature"] = sig
602+
}
603+
}
599604
return c.Send(msg)
600605
}
601606

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package client
4+
5+
import (
6+
"crypto/ed25519"
7+
"crypto/rand"
8+
"encoding/base64"
9+
"fmt"
10+
"testing"
11+
)
12+
13+
func TestRegisterWithKeyOptsCarriesProofOfPossession(t *testing.T) {
14+
t.Parallel()
15+
pub, priv, err := ed25519.GenerateKey(rand.Reader)
16+
if err != nil {
17+
t.Fatalf("keygen: %v", err)
18+
}
19+
pubB64 := base64.StdEncoding.EncodeToString(pub)
20+
21+
srv := newFakeJSONServer(t, echoHandler())
22+
defer srv.close()
23+
c, _ := Dial(srv.addr())
24+
defer c.Close()
25+
26+
c.SetSigner(func(challenge string) string {
27+
return base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(challenge)))
28+
})
29+
30+
const listenAddr = "1.2.3.4:4000"
31+
resp, err := c.RegisterWithKeyOpts(RegisterOpts{
32+
ListenAddr: listenAddr,
33+
PublicKey: pubB64,
34+
})
35+
if err != nil {
36+
t.Fatalf("register: %v", err)
37+
}
38+
echo, _ := resp["echo"].(map[string]interface{})
39+
sigB64, ok := echo["signature"].(string)
40+
if !ok || sigB64 == "" {
41+
t.Fatalf("register message carries no signature: %#v", echo)
42+
}
43+
sig, err := base64.StdEncoding.DecodeString(sigB64)
44+
if err != nil {
45+
t.Fatalf("signature not base64: %v", err)
46+
}
47+
challenge := fmt.Sprintf("register:%s:%s", listenAddr, pubB64)
48+
if !ed25519.Verify(pub, []byte(challenge), sig) {
49+
t.Fatalf("signature does not verify against submitted public_key for %q", challenge)
50+
}
51+
}
52+
53+
func TestRegisterWithKeyReRegistrationBindsPublicKey(t *testing.T) {
54+
t.Parallel()
55+
pub, priv, err := ed25519.GenerateKey(rand.Reader)
56+
if err != nil {
57+
t.Fatalf("keygen: %v", err)
58+
}
59+
pubB64 := base64.StdEncoding.EncodeToString(pub)
60+
61+
srv := newFakeJSONServer(t, echoHandler())
62+
defer srv.close()
63+
c, _ := Dial(srv.addr())
64+
defer c.Close()
65+
66+
c.SetSigner(func(challenge string) string {
67+
return base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(challenge)))
68+
})
69+
70+
const listenAddr = "x:1"
71+
resp, err := c.RegisterWithKey(listenAddr, pubB64, "bob", []string{"10.0.0.1:80"}, "v1.2.3")
72+
if err != nil {
73+
t.Fatalf("register: %v", err)
74+
}
75+
echo, _ := resp["echo"].(map[string]interface{})
76+
sigB64, _ := echo["signature"].(string)
77+
sig, err := base64.StdEncoding.DecodeString(sigB64)
78+
if err != nil || sigB64 == "" {
79+
t.Fatalf("re-registration carries no verifiable signature: %q (%v)", sigB64, err)
80+
}
81+
challenge := fmt.Sprintf("register:%s:%s", listenAddr, pubB64)
82+
if !ed25519.Verify(pub, []byte(challenge), sig) {
83+
t.Fatalf("re-registration signature does not bind submitted public_key")
84+
}
85+
}
86+
87+
func TestRegisterWithKeyOptsNoSignerOmitsSignature(t *testing.T) {
88+
t.Parallel()
89+
srv := newFakeJSONServer(t, echoHandler())
90+
defer srv.close()
91+
c, _ := Dial(srv.addr())
92+
defer c.Close()
93+
94+
resp, err := c.RegisterWithKeyOpts(RegisterOpts{ListenAddr: "x:1", PublicKey: "PUB=="})
95+
if err != nil {
96+
t.Fatalf("register without signer should still succeed: %v", err)
97+
}
98+
echo, _ := resp["echo"].(map[string]interface{})
99+
if _, ok := echo["signature"]; ok {
100+
t.Fatalf("signature must be omitted when no signer is configured (backward compatibility)")
101+
}
102+
if got, _ := echo["public_key"].(string); got != "PUB==" {
103+
t.Fatalf("public_key: %q", got)
104+
}
105+
}

0 commit comments

Comments
 (0)