Skip to content

Commit bb200b3

Browse files
committed
feat(secure): support cold-start key exchange with empty pre-shared keys
Allow key exchange to proceed when both pipeline and implant enable secure mode but neither provides pre-shared Age keys. The first communication happens in plaintext; once key exchange completes the session switches to encrypted. Changes: - GetKeyPairForSession returns empty KeyPair instead of nil when secure is enabled but keys are empty, so the parser stays in secure-aware plaintext mode and picks up new keys after PushCtrl. - initializeSecureManager safely handles nil ImplantKeypair/ServerKeypair to prevent panics on pipelines with no pre-shared keys. - Move ResetCounters into the successful key-exchange callback so a failed attempt triggers an immediate retry on the next checkin instead of waiting 100 messages.
1 parent 7a586da commit bb200b3

7 files changed

Lines changed: 250 additions & 4 deletions

File tree

server/internal/core/connection.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,11 @@ func GetKeyPairForSession(sid uint32, secureConfig *implanttypes.SecureConfig) *
118118
}
119119

120120
if publicKey == "" && len(privateCandidates) == 0 {
121-
return nil
121+
// secure is enabled but no keys established yet (cold start scenario).
122+
// Return an empty KeyPair so the parser enters "secure but plaintext" mode;
123+
// once key exchange completes and PushCtrl syncs the new keys, the parser
124+
// will pick them up on the next GetConnection / WithSecure call.
125+
return &clientpb.KeyPair{}
122126
}
123127

124128
return &clientpb.KeyPair{
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package core
2+
3+
import (
4+
"testing"
5+
6+
"github.com/chainreactors/IoM-go/proto/client/clientpb"
7+
"github.com/chainreactors/malice-network/helper/implanttypes"
8+
)
9+
10+
// TestGetKeyPairForSession_Disabled verifies that nil is returned when
11+
// secure is not enabled, regardless of key content.
12+
func TestGetKeyPairForSession_Disabled(t *testing.T) {
13+
t.Parallel()
14+
cfg := &implanttypes.SecureConfig{Enable: false}
15+
kp := GetKeyPairForSession(1, cfg)
16+
if kp != nil {
17+
t.Fatal("expected nil KeyPair when secure is disabled")
18+
}
19+
}
20+
21+
// TestGetKeyPairForSession_NilConfig verifies nil config returns nil KeyPair.
22+
func TestGetKeyPairForSession_NilConfig(t *testing.T) {
23+
t.Parallel()
24+
kp := GetKeyPairForSession(1, nil)
25+
if kp != nil {
26+
t.Fatal("expected nil KeyPair for nil config")
27+
}
28+
}
29+
30+
// TestGetKeyPairForSession_ColdStart verifies that an empty (non-nil) KeyPair
31+
// is returned when secure is enabled but no pre-shared keys exist.
32+
// This is the cold-start scenario where encryption starts after key exchange.
33+
func TestGetKeyPairForSession_ColdStart(t *testing.T) {
34+
t.Parallel()
35+
cfg := &implanttypes.SecureConfig{
36+
Enable: true,
37+
ServerPublicKey: "",
38+
ServerPrivateKey: "",
39+
ImplantPublicKey: "",
40+
}
41+
kp := GetKeyPairForSession(42, cfg)
42+
if kp == nil {
43+
t.Fatal("expected non-nil KeyPair for cold-start secure config")
44+
}
45+
if kp.PublicKey != "" {
46+
t.Errorf("PublicKey = %q, want empty", kp.PublicKey)
47+
}
48+
if kp.PrivateKey != "" {
49+
t.Errorf("PrivateKey = %q, want empty", kp.PrivateKey)
50+
}
51+
}
52+
53+
// TestGetKeyPairForSession_WithPreSharedKeys verifies that pre-shared keys
54+
// are correctly assembled into the KeyPair.
55+
func TestGetKeyPairForSession_WithPreSharedKeys(t *testing.T) {
56+
t.Parallel()
57+
cfg := &implanttypes.SecureConfig{
58+
Enable: true,
59+
ServerPrivateKey: "server-priv",
60+
ImplantPublicKey: "implant-pub",
61+
}
62+
kp := GetKeyPairForSession(99, cfg)
63+
if kp == nil {
64+
t.Fatal("expected non-nil KeyPair")
65+
}
66+
if kp.PublicKey != "implant-pub" {
67+
t.Errorf("PublicKey = %q, want %q", kp.PublicKey, "implant-pub")
68+
}
69+
if kp.PrivateKey != "server-priv" {
70+
t.Errorf("PrivateKey = %q, want %q", kp.PrivateKey, "server-priv")
71+
}
72+
}
73+
74+
// TestGetKeyPairForSession_AfterKeyExchange verifies that session-level keys
75+
// (from key exchange) take priority over pipeline-level pre-shared keys.
76+
func TestGetKeyPairForSession_AfterKeyExchange(t *testing.T) {
77+
t.Parallel()
78+
var sid uint32 = 200
79+
cfg := &implanttypes.SecureConfig{
80+
Enable: true,
81+
ServerPrivateKey: "pipeline-server-priv",
82+
ImplantPublicKey: "pipeline-implant-pub",
83+
}
84+
85+
// Simulate key exchange result stored in ListenerSessions
86+
ListenerSessions.Add(&clientpb.Session{
87+
RawId: sid,
88+
KeyPair: &clientpb.KeyPair{
89+
PublicKey: "exchanged-pub",
90+
PrivateKey: "exchanged-priv",
91+
},
92+
})
93+
t.Cleanup(func() { ListenerSessions.Remove(sid) })
94+
95+
kp := GetKeyPairForSession(sid, cfg)
96+
if kp == nil {
97+
t.Fatal("expected non-nil KeyPair")
98+
}
99+
if kp.PublicKey != "exchanged-pub" {
100+
t.Errorf("PublicKey = %q, want %q", kp.PublicKey, "exchanged-pub")
101+
}
102+
// PrivateKey should contain both exchanged and pipeline keys
103+
if kp.PrivateKey == "" {
104+
t.Fatal("PrivateKey should not be empty")
105+
}
106+
}

server/internal/core/secure_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,44 @@ func TestSecureManager_CounterOverflow(t *testing.T) {
290290
}
291291
}
292292

293+
// TestSecureManager_EmptyKeyPairSession verifies that a SecureManager created
294+
// with an empty (non-nil, zero-value) KeyPair still works correctly.
295+
// This is the cold-start scenario where keys will be populated after exchange.
296+
func TestSecureManager_EmptyKeyPairSession(t *testing.T) {
297+
t.Parallel()
298+
sess := &Session{
299+
ID: "empty-keypair-session",
300+
Type: "test",
301+
SessionContext: &client.SessionContext{
302+
SessionInfo: &client.SessionInfo{},
303+
KeyPair: &clientpb.KeyPair{PublicKey: "", PrivateKey: ""},
304+
},
305+
}
306+
307+
sm := NewSecureSpiteManager(sess)
308+
if sm == nil {
309+
t.Fatal("NewSecureSpiteManager returned nil")
310+
}
311+
312+
// Counter operations must work normally
313+
for i := 0; i < 100; i++ {
314+
sm.IncrementCounter()
315+
}
316+
if !sm.ShouldRotateKey() {
317+
t.Error("ShouldRotateKey should be true at counter=100 even with empty keys")
318+
}
319+
320+
// UpdateKeyPair should transition to real keys
321+
sm.UpdateKeyPair(&clientpb.KeyPair{
322+
PublicKey: "exchanged-pub",
323+
PrivateKey: "exchanged-priv",
324+
})
325+
sm.ResetCounters()
326+
if sm.ShouldRotateKey() {
327+
t.Error("ShouldRotateKey should be false after reset")
328+
}
329+
}
330+
293331
// TestSecureManager_NilKeyPairSession verifies creating a SecureManager with
294332
// a session that has a nil KeyPair does not panic.
295333
func TestSecureManager_NilKeyPairSession(t *testing.T) {

server/internal/core/session.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -972,9 +972,19 @@ func (s *Session) initializeSecureManager(req *clientpb.RegisterSession) error {
972972
}
973973

974974
if s.KeyPair == nil {
975+
// Populate from pipeline pre-shared keys when available (may be empty
976+
// in cold-start scenarios where no keys are distributed ahead of time).
977+
pubKey := ""
978+
privKey := ""
979+
if pipeline.Secure.ImplantKeypair != nil {
980+
pubKey = pipeline.Secure.ImplantKeypair.PublicKey
981+
}
982+
if pipeline.Secure.ServerKeypair != nil {
983+
privKey = pipeline.Secure.ServerKeypair.PrivateKey
984+
}
975985
s.KeyPair = &clientpb.KeyPair{
976-
PublicKey: pipeline.Secure.ImplantKeypair.PublicKey,
977-
PrivateKey: pipeline.Secure.ServerKeypair.PrivateKey,
986+
PublicKey: pubKey,
987+
PrivateKey: privKey,
978988
}
979989
}
980990

server/real_implant_e2e_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,76 @@ func TestRealImplantSecureKeyExchangeE2E(t *testing.T) {
470470
t.Log("secure key exchange E2E test passed: cold start → key exchange → encrypted commands")
471471
}
472472

473+
// TestRealImplantFullColdStartE2E verifies key exchange works when the pipeline
474+
// has NO pre-shared keys at all (neither server nor implant keypair). Both sides
475+
// start with empty keys; the server triggers key exchange on first registration
476+
// and the implant generates its keypair during the exchange.
477+
func TestRealImplantFullColdStartE2E(t *testing.T) {
478+
testsupport.RequireRealImplantEnv(t)
479+
480+
h := testsupport.NewControlPlaneHarness(t)
481+
listenerName := fmt.Sprintf("real-fullcold-listener-%d", time.Now().UnixNano())
482+
pipelineName := fmt.Sprintf("real-fullcold-pipe-%d", time.Now().UnixNano())
483+
484+
// Create a fully cold-start pipeline (no keys at all)
485+
pipeline := testsupport.NewRealFullColdStartTCPPipeline(t, listenerName, pipelineName)
486+
implant := testsupport.NewRealImplant(t, h, pipeline)
487+
if err := implant.Start(t); err != nil {
488+
t.Fatalf("real full-cold-start implant start failed: %v", err)
489+
}
490+
491+
// Wait for session registration + initial key exchange
492+
waitRealActiveConnection(t, implant.SessionID)
493+
waitRealPostRegisterCheckin(t, implant.SessionID)
494+
495+
// Verify session was registered with secure mode
496+
runtimeSession := mustRealRuntimeSession(t, implant.SessionID)
497+
if runtimeSession.SecureManager == nil {
498+
t.Fatal("session should have a SecureManager after full cold-start registration")
499+
}
500+
501+
// Connect as admin client
502+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
503+
defer cancel()
504+
conn, err := h.Connect(ctx)
505+
if err != nil {
506+
t.Fatalf("Connect failed: %v", err)
507+
}
508+
t.Cleanup(func() { _ = conn.Close() })
509+
510+
rpc := clientrpc.NewMaliceRPCClient(conn)
511+
sessionCtx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs(
512+
"session_id", implant.SessionID,
513+
"callee", consts.CalleeCMD,
514+
))
515+
516+
// Enable keepalive for faster command execution
517+
enableRealKeepalive(t, &realRPCFixture{
518+
h: h, implant: implant, rpc: rpc, session: sessionCtx,
519+
}, true)
520+
521+
waitRealActiveConnection(t, implant.SessionID)
522+
523+
// Execute pwd command — this goes through age-encrypted channel
524+
pwdTask, err := rpc.Pwd(sessionCtx, &implantpb.Request{Name: consts.ModulePwd})
525+
if err != nil {
526+
t.Fatalf("Pwd (post full cold-start key exchange) failed: %v", err)
527+
}
528+
pwdContent := waitRealTaskFinish(t, rpc, implant.SessionID, pwdTask.TaskId)
529+
pwdOutput := strings.TrimSpace(pwdContent.GetSpite().GetResponse().GetOutput())
530+
if pwdOutput == "" {
531+
t.Fatal("pwd output should not be empty after full cold-start key exchange")
532+
}
533+
t.Logf("pwd after full cold-start key exchange: %s", pwdOutput)
534+
535+
// Disable keepalive
536+
enableRealKeepalive(t, &realRPCFixture{
537+
h: h, implant: implant, rpc: rpc, session: sessionCtx,
538+
}, false)
539+
540+
t.Log("full cold-start key exchange E2E test passed: no pre-shared keys → key exchange → encrypted commands")
541+
}
542+
473543
// TestRealImplantTLSPipelineE2E verifies the implant can connect to a TLS-enabled
474544
// pipeline (self-signed cert with CA verification) and execute commands normally.
475545
// This tests the full certificate chain: pipeline generates CA+cert → profile

server/rpc/rpc-implant.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,12 +309,16 @@ func (rpc *Server) triggerKeyExchange(ctx context.Context, sess *core.Session) e
309309
Nonce: nonce,
310310
Signature: signature,
311311
}
312-
sess.SecureManager.ResetCounters()
312+
// Reset counter only on successful exchange — if the callback fires, the
313+
// implant accepted the request and sent back its new public key. Resetting
314+
// before the response arrives would swallow a failed attempt and delay the
315+
// next retry by a full rotation budget (100 checkins).
313316
_, err = rpc.GenericInternal(ctx, req, consts.ModuleKeyExchange, func(spite *implantpb.Spite) {
314317
resp := spite.GetKeyExchangeResponse()
315318
if resp == nil {
316319
return
317320
}
321+
sess.SecureManager.ResetCounters()
318322
sess.UpdateKeyPairFieldsAndPushCtrl(resp.PublicKey, keyPair.Private)
319323
})
320324
return err

server/testsupport/real_implant.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,20 @@ func NewRealSecureTCPPipeline(t testing.TB, listenerName, pipelineName string) *
163163
return pipeline
164164
}
165165

166+
// NewRealFullColdStartTCPPipeline creates a TCP pipeline with age key exchange
167+
// enabled but NO pre-shared keys at all (neither server nor implant keypair).
168+
// Both sides start from scratch; the first key exchange establishes encryption.
169+
func NewRealFullColdStartTCPPipeline(t testing.TB, listenerName, pipelineName string) *clientpb.Pipeline {
170+
t.Helper()
171+
172+
pipeline := NewRealTCPPipeline(t, listenerName, pipelineName)
173+
pipeline.Secure = &clientpb.Secure{
174+
Enable: true,
175+
// Both keypairs intentionally left empty for full cold-start scenario
176+
}
177+
return pipeline
178+
}
179+
166180
// NewRealTLSTCPPipeline creates a TCP pipeline with TLS enabled (self-signed cert).
167181
// The pipeline generates its own CA + server certificate, so the implant can
168182
// verify the server using the embedded CA cert.

0 commit comments

Comments
 (0)