Skip to content

Commit 7dc5648

Browse files
gcmsgclaude
andcommitted
fix: sign full envelope instead of just payload (C-01)
Add SigningPayload(), SetSignature(), GetSignature() to Envelope implementing the SignableEnvelope interface. The signing payload is a SHA-256 hash of Source, Destination, Protocol, MessageType, Nonce, Timestamp, and Payload, preventing attackers from modifying routing fields without invalidating the signature. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent adc2341 commit 7dc5648

File tree

2 files changed

+124
-0
lines changed

2 files changed

+124
-0
lines changed

envelope/envelope.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package envelope
22

33
import (
4+
"crypto/sha256"
5+
"encoding/binary"
46
"time"
57

68
"github.com/google/uuid"
@@ -100,3 +102,40 @@ func (e *Envelope) WithMetadata(key, value string) *Envelope {
100102
e.Metadata[key] = value
101103
return e
102104
}
105+
106+
// SigningPayload returns a canonical byte representation of the envelope's
107+
// security-critical fields for signing. This covers Source, Destination,
108+
// Protocol, MessageType, Nonce, Timestamp, and Payload — preventing an
109+
// attacker from modifying routing or replay-protection fields without
110+
// invalidating the signature.
111+
//
112+
// This implements the identity.SignableEnvelope interface.
113+
func (e *Envelope) SigningPayload() []byte {
114+
h := sha256.New()
115+
h.Write([]byte(e.Source))
116+
h.Write([]byte{0}) // separator
117+
h.Write([]byte(e.Destination))
118+
h.Write([]byte{0})
119+
h.Write([]byte(e.Protocol))
120+
h.Write([]byte{0})
121+
h.Write([]byte(e.MessageType))
122+
h.Write([]byte{0})
123+
h.Write([]byte(e.Nonce))
124+
h.Write([]byte{0})
125+
// Encode timestamp as Unix nanoseconds for deterministic representation.
126+
var tsBuf [8]byte
127+
binary.BigEndian.PutUint64(tsBuf[:], uint64(e.Timestamp.UnixNano()))
128+
h.Write(tsBuf[:])
129+
h.Write(e.Payload)
130+
return h.Sum(nil)
131+
}
132+
133+
// SetSignature sets the envelope's signature field.
134+
func (e *Envelope) SetSignature(sig string) {
135+
e.Signature = sig
136+
}
137+
138+
// GetSignature returns the envelope's signature field.
139+
func (e *Envelope) GetSignature() string {
140+
return e.Signature
141+
}

envelope/envelope_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package envelope
22

33
import (
4+
"bytes"
45
"testing"
6+
"time"
57

8+
"github.com/peerclaw/peerclaw-core/identity"
69
"github.com/peerclaw/peerclaw-core/protocol"
710
)
811

@@ -75,3 +78,85 @@ func TestNewResponse_SetsPayload(t *testing.T) {
7578
t.Errorf("expected Payload world, got %s", string(resp.Payload))
7679
}
7780
}
81+
82+
func TestSigningPayload_Deterministic(t *testing.T) {
83+
env := &Envelope{
84+
Source: "alice",
85+
Destination: "bob",
86+
Protocol: protocol.ProtocolA2A,
87+
MessageType: MessageTypeRequest,
88+
Nonce: "nonce-123",
89+
Timestamp: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
90+
Payload: []byte("hello"),
91+
}
92+
93+
p1 := env.SigningPayload()
94+
p2 := env.SigningPayload()
95+
if !bytes.Equal(p1, p2) {
96+
t.Error("SigningPayload should be deterministic")
97+
}
98+
}
99+
100+
func TestSigningPayload_ChangesOnFieldMutation(t *testing.T) {
101+
env := &Envelope{
102+
Source: "alice",
103+
Destination: "bob",
104+
Protocol: protocol.ProtocolA2A,
105+
MessageType: MessageTypeRequest,
106+
Nonce: "nonce-123",
107+
Timestamp: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
108+
Payload: []byte("hello"),
109+
}
110+
111+
baseline := env.SigningPayload()
112+
113+
// Changing any covered field should produce a different signing payload.
114+
fields := []struct {
115+
name string
116+
mutate func()
117+
revert func()
118+
}{
119+
{"Source", func() { env.Source = "mallory" }, func() { env.Source = "alice" }},
120+
{"Destination", func() { env.Destination = "eve" }, func() { env.Destination = "bob" }},
121+
{"Protocol", func() { env.Protocol = protocol.ProtocolMCP }, func() { env.Protocol = protocol.ProtocolA2A }},
122+
{"MessageType", func() { env.MessageType = MessageTypeResponse }, func() { env.MessageType = MessageTypeRequest }},
123+
{"Nonce", func() { env.Nonce = "other" }, func() { env.Nonce = "nonce-123" }},
124+
{"Timestamp", func() { env.Timestamp = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) }, func() { env.Timestamp = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }},
125+
{"Payload", func() { env.Payload = []byte("tampered") }, func() { env.Payload = []byte("hello") }},
126+
}
127+
128+
for _, f := range fields {
129+
f.mutate()
130+
changed := env.SigningPayload()
131+
if bytes.Equal(baseline, changed) {
132+
t.Errorf("changing %s should produce different signing payload", f.name)
133+
}
134+
f.revert()
135+
}
136+
}
137+
138+
func TestSignEnvelope_VerifyEnvelope_RoundTrip(t *testing.T) {
139+
kp, err := identity.GenerateKeypair()
140+
if err != nil {
141+
t.Fatalf("GenerateKeypair: %v", err)
142+
}
143+
144+
env := New("alice", "bob", protocol.ProtocolA2A, []byte("hello"))
145+
env.Nonce = "test-nonce"
146+
identity.SignEnvelope(env, kp.PrivateKey)
147+
148+
if env.Signature == "" {
149+
t.Fatal("expected non-empty signature")
150+
}
151+
152+
pubKey, _ := identity.ParsePublicKey(kp.PublicKeyString())
153+
if err := identity.VerifyEnvelope(env, pubKey); err != nil {
154+
t.Fatalf("VerifyEnvelope should pass: %v", err)
155+
}
156+
157+
// Tamper with Source — should fail.
158+
env.Source = "mallory"
159+
if err := identity.VerifyEnvelope(env, pubKey); err == nil {
160+
t.Error("tampered Source should fail verification")
161+
}
162+
}

0 commit comments

Comments
 (0)