Skip to content

Commit a88c9cf

Browse files
gcmsgclaude
andcommitted
feat: add PoP registration, expand handleListAgents, structured errors
Phase 17 Batch 2: Proof-of-Possession verification on agent registration, extend handleListAgents with sort/search/min_score/page_size params, structured jsonError responses using core/errors, and refactor validateRegisterRequest to delegate to Card.Validate(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 581df54 commit a88c9cf

File tree

2 files changed

+98
-97
lines changed

2 files changed

+98
-97
lines changed

internal/server/http.go

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
package server
22

33
import (
4+
"bytes"
45
"context"
56
"crypto/subtle"
67
"encoding/json"
8+
"io"
79
"log/slog"
810
"net/http"
11+
"strconv"
912
"time"
1013

1114
"github.com/peerclaw/peerclaw-core/agentcard"
15+
pcerrors "github.com/peerclaw/peerclaw-core/errors"
16+
coreidentity "github.com/peerclaw/peerclaw-core/identity"
1217
"github.com/peerclaw/peerclaw-core/protocol"
1318
coresignaling "github.com/peerclaw/peerclaw-core/signaling"
1419
"github.com/peerclaw/peerclaw-server/internal/audit"
@@ -453,8 +458,15 @@ type peerclawReq struct {
453458
}
454459

455460
func (s *HTTPServer) handleRegister(w http.ResponseWriter, r *http.Request) {
461+
// Buffer body for PoP signature verification.
462+
bodyBytes, err := io.ReadAll(r.Body)
463+
if err != nil {
464+
s.jsonError(w, "failed to read request body", http.StatusBadRequest)
465+
return
466+
}
467+
456468
var req registerRequest
457-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
469+
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&req); err != nil {
458470
s.jsonError(w, "invalid request body", http.StatusBadRequest)
459471
return
460472
}
@@ -464,6 +476,30 @@ func (s *HTTPServer) handleRegister(w http.ResponseWriter, r *http.Request) {
464476
return
465477
}
466478

479+
// Proof-of-Possession: if public_key is provided, require matching signature.
480+
if req.PublicKey != "" {
481+
sigHeader := r.Header.Get("X-PeerClaw-Signature")
482+
pubKeyHeader := r.Header.Get("X-PeerClaw-PublicKey")
483+
if sigHeader == "" || pubKeyHeader == "" {
484+
s.jsonError(w, "proof-of-possession required: sign the request body with your Ed25519 key", http.StatusBadRequest)
485+
return
486+
}
487+
if pubKeyHeader != req.PublicKey {
488+
s.jsonError(w, "X-PeerClaw-PublicKey must match public_key in request body", http.StatusBadRequest)
489+
return
490+
}
491+
// Verify signature over the raw request body.
492+
pubKey, parseErr := coreidentity.ParsePublicKey(pubKeyHeader)
493+
if parseErr != nil {
494+
s.jsonError(w, "invalid public key: "+parseErr.Error(), http.StatusBadRequest)
495+
return
496+
}
497+
if verifyErr := coreidentity.Verify(pubKey, bodyBytes, sigHeader); verifyErr != nil {
498+
s.jsonError(w, "proof-of-possession signature verification failed", http.StatusBadRequest)
499+
return
500+
}
501+
}
502+
467503
protocols := make([]protocol.Protocol, len(req.Protocols))
468504
for i, p := range req.Protocols {
469505
protocols[i] = protocol.Protocol(p)
@@ -522,11 +558,24 @@ func (s *HTTPServer) handleRegister(w http.ResponseWriter, r *http.Request) {
522558
}
523559

524560
func (s *HTTPServer) handleListAgents(w http.ResponseWriter, r *http.Request) {
561+
q := r.URL.Query()
525562
filter := registry.ListFilter{
526-
Protocol: r.URL.Query().Get("protocol"),
527-
Capability: r.URL.Query().Get("capability"),
528-
Status: agentcard.AgentStatus(r.URL.Query().Get("status")),
529-
PageToken: r.URL.Query().Get("page_token"),
563+
Protocol: q.Get("protocol"),
564+
Capability: q.Get("capability"),
565+
Status: agentcard.AgentStatus(q.Get("status")),
566+
PageToken: q.Get("page_token"),
567+
SortBy: q.Get("sort"),
568+
Search: q.Get("search"),
569+
}
570+
if ms := q.Get("min_score"); ms != "" {
571+
if score, err := strconv.ParseFloat(ms, 64); err == nil {
572+
filter.MinScore = score
573+
}
574+
}
575+
if ps := q.Get("page_size"); ps != "" {
576+
if size, err := strconv.Atoi(ps); err == nil {
577+
filter.PageSize = size
578+
}
530579
}
531580
result, err := s.registry.ListAgents(r.Context(), filter)
532581
if err != nil {
@@ -931,5 +980,26 @@ func (s *HTTPServer) jsonResponse(w http.ResponseWriter, status int, data any) {
931980
}
932981

933982
func (s *HTTPServer) jsonError(w http.ResponseWriter, message string, status int) {
934-
s.jsonResponse(w, status, map[string]string{"error": message})
983+
code := statusToErrorCode(status)
984+
s.jsonResponse(w, status, pcerrors.Error{Code: code, Message: message})
985+
}
986+
987+
// statusToErrorCode maps HTTP status codes to structured error codes.
988+
func statusToErrorCode(status int) pcerrors.Code {
989+
switch status {
990+
case http.StatusNotFound:
991+
return pcerrors.CodeNotFound
992+
case http.StatusBadRequest:
993+
return pcerrors.CodeValidation
994+
case http.StatusUnauthorized, http.StatusForbidden:
995+
return pcerrors.CodeAuth
996+
case http.StatusConflict:
997+
return pcerrors.CodeConflict
998+
case http.StatusRequestTimeout, http.StatusGatewayTimeout:
999+
return pcerrors.CodeTimeout
1000+
case http.StatusTooManyRequests:
1001+
return pcerrors.CodeRateLimited
1002+
default:
1003+
return pcerrors.CodeInternal
1004+
}
9351005
}

internal/server/validation.go

Lines changed: 22 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,11 @@
11
package server
22

33
import (
4-
"encoding/base64"
5-
"crypto/ed25519"
64
"fmt"
7-
"net/url"
8-
"strings"
9-
"unicode"
10-
)
115

12-
// knownProtocols are the valid protocol values for agent registration.
13-
var knownProtocols = map[string]bool{
14-
"a2a": true,
15-
"mcp": true,
16-
"acp": true,
17-
"custom": true,
18-
"peerclaw": true,
19-
}
6+
"github.com/peerclaw/peerclaw-core/agentcard"
7+
"github.com/peerclaw/peerclaw-core/protocol"
8+
)
209

2110
// validStatuses are the valid heartbeat status values.
2211
var validStatuses = map[string]bool{
@@ -26,76 +15,26 @@ var validStatuses = map[string]bool{
2615
"offline": true,
2716
}
2817

18+
// validateRegisterRequest validates a registration request by building a Card
19+
// and delegating to Card.Validate().
2920
func validateRegisterRequest(req *registerRequest) error {
30-
// Name: 1-256 chars, no control characters.
31-
if req.Name == "" {
32-
return fmt.Errorf("name is required")
33-
}
34-
if len(req.Name) > 256 {
35-
return fmt.Errorf("name must be at most 256 characters")
36-
}
37-
if containsControlChars(req.Name) {
38-
return fmt.Errorf("name must not contain control characters")
39-
}
40-
41-
// PublicKey: if provided, must be valid base64-encoded Ed25519 key (32 bytes).
42-
if req.PublicKey != "" {
43-
keyBytes, err := base64.StdEncoding.DecodeString(req.PublicKey)
44-
if err != nil {
45-
return fmt.Errorf("public_key must be valid base64: %w", err)
46-
}
47-
if len(keyBytes) != ed25519.PublicKeySize {
48-
return fmt.Errorf("public_key must be %d bytes (Ed25519), got %d", ed25519.PublicKeySize, len(keyBytes))
49-
}
50-
}
51-
52-
// Capabilities: max 50 items, each ≤128 chars.
53-
if len(req.Capabilities) > 50 {
54-
return fmt.Errorf("capabilities must have at most 50 items")
55-
}
56-
for _, cap := range req.Capabilities {
57-
if len(cap) > 128 {
58-
return fmt.Errorf("each capability must be at most 128 characters")
59-
}
60-
}
61-
62-
// Endpoint URL: if provided, must be valid URL.
63-
if req.Endpoint.URL != "" {
64-
u, err := url.Parse(req.Endpoint.URL)
65-
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
66-
return fmt.Errorf("endpoint URL must be a valid http/https URL")
67-
}
68-
}
69-
70-
// Endpoint Port: 1-65535.
71-
if req.Endpoint.Port < 0 || req.Endpoint.Port > 65535 {
72-
return fmt.Errorf("endpoint port must be between 0 and 65535")
73-
}
74-
75-
// Protocols: max 10, must be known.
76-
if len(req.Protocols) > 10 {
77-
return fmt.Errorf("protocols must have at most 10 items")
78-
}
79-
for _, p := range req.Protocols {
80-
if !knownProtocols[strings.ToLower(p)] {
81-
return fmt.Errorf("unknown protocol: %s", p)
82-
}
83-
}
84-
85-
// Metadata: max 50 keys, key ≤128, value ≤1024.
86-
if len(req.Metadata) > 50 {
87-
return fmt.Errorf("metadata must have at most 50 keys")
88-
}
89-
for k, v := range req.Metadata {
90-
if len(k) > 128 {
91-
return fmt.Errorf("metadata key must be at most 128 characters")
92-
}
93-
if len(v) > 1024 {
94-
return fmt.Errorf("metadata value must be at most 1024 characters")
95-
}
96-
}
97-
98-
return nil
21+
protocols := make([]protocol.Protocol, len(req.Protocols))
22+
for i, p := range req.Protocols {
23+
protocols[i] = protocol.Protocol(p)
24+
}
25+
26+
card := &agentcard.Card{
27+
Name: req.Name,
28+
PublicKey: req.PublicKey,
29+
Capabilities: req.Capabilities,
30+
Endpoint: agentcard.Endpoint{
31+
URL: req.Endpoint.URL,
32+
Port: req.Endpoint.Port,
33+
},
34+
Protocols: protocols,
35+
Metadata: req.Metadata,
36+
}
37+
return card.Validate()
9938
}
10039

10140
func validateHeartbeatStatus(status string) error {
@@ -108,11 +47,3 @@ func validateHeartbeatStatus(status string) error {
10847
return nil
10948
}
11049

111-
func containsControlChars(s string) bool {
112-
for _, r := range s {
113-
if unicode.IsControl(r) {
114-
return true
115-
}
116-
}
117-
return false
118-
}

0 commit comments

Comments
 (0)