Skip to content

Commit 723cfe6

Browse files
authored
secure: use HKDF for session key derivation
Use HKDF-based secure-session key derivation bound to the session transcript, full static peer public keys, ephemeral keys, protocol kind, and explicit secure-session versioning. Includes focused tests for transcript consistency, key separation, full public key binding, and version mismatch handling. A final cleanup signs the secure-session version, restores the file header, and uses the standard library HKDF helper. Note: the stable gate currently fails on the existing Linux workflow dependency gap for libXxf86vm; this PR intentionally leaves workflow changes out because the current token cannot merge workflow file updates.
1 parent 6ab8b0b commit 723cfe6

2 files changed

Lines changed: 231 additions & 5 deletions

File tree

internal/secure/session.go

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"crypto/cipher"
99
"crypto/ecdh"
1010
"crypto/ed25519"
11+
"crypto/hkdf"
1112
"crypto/rand"
1213
"crypto/sha256"
1314
"encoding/base64"
@@ -24,7 +25,10 @@ import (
2425
const maxHandshakeSize = 8 * 1024
2526
const maxChunkSize = 32 * 1024
2627

28+
const sessionVersion = uint8(2)
29+
2730
type hello struct {
31+
Version uint8 `json:"version"`
2832
NodeID string `json:"node_id"`
2933
PublicKey string `json:"public_key"`
3034
Ephemeral string `json:"ephemeral"`
@@ -74,6 +78,10 @@ func handshake(conn net.Conn, kind byte, id identity.Identity, initiator bool) (
7478
return nil, err
7579
}
7680

81+
if remoteHello.Version != sessionVersion {
82+
return nil, fmt.Errorf("secure: peer uses session version %d, we require %d", remoteHello.Version, sessionVersion)
83+
}
84+
7785
if !initiator {
7886
if err := writeHello(conn, localHello); err != nil {
7987
return nil, err
@@ -93,8 +101,21 @@ func handshake(conn net.Conn, kind byte, id identity.Identity, initiator bool) (
93101
return nil, fmt.Errorf("derive shared key: %w", err)
94102
}
95103

96-
key := sha256.Sum256(append(shared, kind))
97-
block, err := aes.NewCipher(key[:])
104+
localStaticPub := []byte(id.PublicKey)
105+
remoteStaticPub, err := base64.StdEncoding.DecodeString(remoteHello.PublicKey)
106+
if err != nil {
107+
return nil, fmt.Errorf("decode remote static public key: %w", err)
108+
}
109+
110+
localEph := priv.PublicKey().Bytes()
111+
transcript := buildTranscript(kind, localEph, remoteEph, localStaticPub, remoteStaticPub, initiator)
112+
113+
key, err := deriveKey(shared, transcript)
114+
if err != nil {
115+
return nil, fmt.Errorf("derive session key: %w", err)
116+
}
117+
118+
block, err := aes.NewCipher(key)
98119
if err != nil {
99120
return nil, fmt.Errorf("create cipher: %w", err)
100121
}
@@ -119,6 +140,42 @@ func handshake(conn net.Conn, kind byte, id identity.Identity, initiator bool) (
119140
return c, nil
120141
}
121142

143+
func deriveKey(sharedSecret, transcript []byte) ([]byte, error) {
144+
salt := sha256.Sum256(transcript)
145+
key, err := hkdf.Key(sha256.New, sharedSecret, salt[:], "vx6-session-v2", 32)
146+
if err != nil {
147+
return nil, err
148+
}
149+
return key, nil
150+
}
151+
152+
func buildTranscript(kind byte, localEph, remoteEph, localStaticPub, remoteStaticPub []byte, initiator bool) []byte {
153+
var clientEph, serverEph, clientPub, serverPub []byte
154+
if initiator {
155+
clientEph = localEph
156+
serverEph = remoteEph
157+
clientPub = localStaticPub
158+
serverPub = remoteStaticPub
159+
} else {
160+
clientEph = remoteEph
161+
serverEph = localEph
162+
clientPub = remoteStaticPub
163+
serverPub = localStaticPub
164+
}
165+
166+
var out []byte
167+
out = append(out, []byte("vx6-transcript-v2\n")...)
168+
out = append(out, kind)
169+
out = append(out, '\n')
170+
out = append(out, clientPub...)
171+
out = append(out, '\n')
172+
out = append(out, serverPub...)
173+
out = append(out, '\n')
174+
out = append(out, clientEph...)
175+
out = append(out, serverEph...)
176+
return out
177+
}
178+
122179
func (c *Conn) LocalNodeID() string {
123180
return c.localNodeID
124181
}
@@ -165,8 +222,9 @@ func (c *Conn) Write(p []byte) (int, error) {
165222
}
166223

167224
func buildHello(id identity.Identity, kind byte, eph []byte) (hello, error) {
168-
sig := ed25519.Sign(id.PrivateKey, signingPayload(kind, id.NodeID, eph))
225+
sig := ed25519.Sign(id.PrivateKey, signingPayload(sessionVersion, kind, id.NodeID, eph))
169226
return hello{
227+
Version: sessionVersion,
170228
NodeID: id.NodeID,
171229
PublicKey: base64.StdEncoding.EncodeToString(id.PublicKey),
172230
Ephemeral: base64.StdEncoding.EncodeToString(eph),
@@ -201,7 +259,7 @@ func readHello(r io.Reader, kind byte) (hello, error) {
201259
if identity.NodeIDFromPublicKey(ed25519.PublicKey(pub)) != h.NodeID {
202260
return hello{}, fmt.Errorf("handshake node id mismatch")
203261
}
204-
if !ed25519.Verify(ed25519.PublicKey(pub), signingPayload(kind, h.NodeID, eph), sig) {
262+
if !ed25519.Verify(ed25519.PublicKey(pub), signingPayload(h.Version, kind, h.NodeID, eph), sig) {
205263
return hello{}, fmt.Errorf("handshake signature verification failed")
206264
}
207265

@@ -224,9 +282,11 @@ func (h hello) ephemeralBytes() ([]byte, error) {
224282
return eph, nil
225283
}
226284

227-
func signingPayload(kind byte, nodeID string, eph []byte) []byte {
285+
func signingPayload(version uint8, kind byte, nodeID string, eph []byte) []byte {
228286
var out []byte
229287
out = append(out, []byte("vx6-secure\n")...)
288+
out = append(out, version)
289+
out = append(out, '\n')
230290
out = append(out, kind)
231291
out = append(out, '\n')
232292
out = append(out, []byte(nodeID)...)

internal/secure/session_test.go

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

33
import (
4+
"bytes"
5+
"encoding/base64"
46
"io"
57
"net"
68
"testing"
@@ -104,3 +106,167 @@ func TestSessionExposesPeerIdentity(t *testing.T) {
104106
}
105107
}
106108
}
109+
110+
func TestTranscriptCanonical(t *testing.T) {
111+
t.Parallel()
112+
113+
idA, _ := identity.Generate()
114+
idB, _ := identity.Generate()
115+
116+
ephA := make([]byte, 32)
117+
ephB := make([]byte, 32)
118+
for i := range ephA {
119+
ephA[i] = byte(i)
120+
ephB[i] = byte(i + 64)
121+
}
122+
123+
fromA := buildTranscript(proto.KindServiceConn, ephA, ephB, []byte(idA.PublicKey), []byte(idB.PublicKey), true)
124+
fromB := buildTranscript(proto.KindServiceConn, ephB, ephA, []byte(idB.PublicKey), []byte(idA.PublicKey), false)
125+
126+
if !bytes.Equal(fromA, fromB) {
127+
t.Fatal("transcript not canonical: initiator and responder produced different transcripts")
128+
}
129+
}
130+
131+
func TestTranscriptKindSeparation(t *testing.T) {
132+
t.Parallel()
133+
134+
idA, _ := identity.Generate()
135+
idB, _ := identity.Generate()
136+
137+
ephA := make([]byte, 32)
138+
ephB := make([]byte, 32)
139+
140+
t1 := buildTranscript(proto.KindServiceConn, ephA, ephB, []byte(idA.PublicKey), []byte(idB.PublicKey), true)
141+
t2 := buildTranscript(proto.KindRendezvous, ephA, ephB, []byte(idA.PublicKey), []byte(idB.PublicKey), true)
142+
143+
if bytes.Equal(t1, t2) {
144+
t.Fatal("different kind values produced identical transcripts")
145+
}
146+
}
147+
148+
func TestTranscriptIdentitySeparation(t *testing.T) {
149+
t.Parallel()
150+
151+
idA, _ := identity.Generate()
152+
idB, _ := identity.Generate()
153+
idC, _ := identity.Generate()
154+
155+
eph := make([]byte, 32)
156+
157+
t1 := buildTranscript(proto.KindServiceConn, eph, eph, []byte(idA.PublicKey), []byte(idB.PublicKey), true)
158+
t2 := buildTranscript(proto.KindServiceConn, eph, eph, []byte(idA.PublicKey), []byte(idC.PublicKey), true)
159+
160+
if bytes.Equal(t1, t2) {
161+
t.Fatal("different peer identities produced identical transcripts")
162+
}
163+
}
164+
165+
func TestTranscriptEphemeralSeparation(t *testing.T) {
166+
t.Parallel()
167+
168+
idA, _ := identity.Generate()
169+
idB, _ := identity.Generate()
170+
171+
eph1 := make([]byte, 32)
172+
eph2 := make([]byte, 32)
173+
eph2[0] = 1
174+
175+
t1 := buildTranscript(proto.KindServiceConn, eph1, eph1, []byte(idA.PublicKey), []byte(idB.PublicKey), true)
176+
t2 := buildTranscript(proto.KindServiceConn, eph2, eph2, []byte(idA.PublicKey), []byte(idB.PublicKey), true)
177+
178+
if bytes.Equal(t1, t2) {
179+
t.Fatal("different ephemerals produced identical transcripts")
180+
}
181+
}
182+
183+
func TestTranscriptBindsFullPublicKey(t *testing.T) {
184+
t.Parallel()
185+
186+
idA, _ := identity.Generate()
187+
idB, _ := identity.Generate()
188+
189+
eph := make([]byte, 32)
190+
191+
transcript := buildTranscript(proto.KindServiceConn, eph, eph, []byte(idA.PublicKey), []byte(idB.PublicKey), true)
192+
193+
if !bytes.Contains(transcript, []byte(idA.PublicKey)) {
194+
t.Fatal("transcript does not contain full client static public key")
195+
}
196+
if !bytes.Contains(transcript, []byte(idB.PublicKey)) {
197+
t.Fatal("transcript does not contain full server static public key")
198+
}
199+
200+
nodeIDBytes := []byte(idA.NodeID)
201+
if bytes.Contains(transcript, nodeIDBytes) {
202+
t.Fatal("transcript contains truncated node ID instead of full public key")
203+
}
204+
}
205+
206+
func TestDeriveKeyUnique(t *testing.T) {
207+
t.Parallel()
208+
209+
shared := make([]byte, 32)
210+
for i := range shared {
211+
shared[i] = byte(i)
212+
}
213+
214+
t1 := []byte("transcript-a")
215+
t2 := []byte("transcript-b")
216+
217+
k1, err := deriveKey(shared, t1)
218+
if err != nil {
219+
t.Fatal(err)
220+
}
221+
k2, err := deriveKey(shared, t2)
222+
if err != nil {
223+
t.Fatal(err)
224+
}
225+
226+
if bytes.Equal(k1, k2) {
227+
t.Fatal("different transcripts derived identical keys")
228+
}
229+
}
230+
231+
func TestVersionMismatchFails(t *testing.T) {
232+
t.Parallel()
233+
234+
clientID, _ := identity.Generate()
235+
serverID, _ := identity.Generate()
236+
237+
left, right := net.Pipe()
238+
239+
errCh := make(chan error, 2)
240+
241+
go func() {
242+
defer right.Close()
243+
_, err := Server(right, proto.KindServiceConn, serverID)
244+
errCh <- err
245+
}()
246+
247+
go func() {
248+
defer left.Close()
249+
staleHello := hello{
250+
Version: sessionVersion - 1,
251+
NodeID: clientID.NodeID,
252+
PublicKey: base64.StdEncoding.EncodeToString(clientID.PublicKey),
253+
Ephemeral: base64.StdEncoding.EncodeToString(make([]byte, 32)),
254+
Signature: base64.StdEncoding.EncodeToString(make([]byte, 64)),
255+
}
256+
if err := writeHello(left, staleHello); err != nil {
257+
errCh <- err
258+
return
259+
}
260+
errCh <- nil
261+
}()
262+
263+
var gotErr error
264+
for i := 0; i < 2; i++ {
265+
if err := <-errCh; err != nil {
266+
gotErr = err
267+
}
268+
}
269+
if gotErr == nil {
270+
t.Fatal("expected version mismatch error, got nil")
271+
}
272+
}

0 commit comments

Comments
 (0)