Skip to content

Commit 5dd2047

Browse files
committed
Harden security and update dependencies
- Remove unencrypted fallbacks in WebSocket send/receive (drop instead) - Add WriteJSON error checking with logging in sendToMobile/sendControlMessage - Add SHA256 checksum verification for auto-update binaries - Add Bearer token authentication for relay API requests - Update to Go 1.25, x/crypto v0.49.0, replace deprecated ScalarBaseMult - Add unit tests for AES-256-GCM encryption and X25519 key exchange
1 parent a11d24a commit 5dd2047

9 files changed

Lines changed: 374 additions & 43 deletions

File tree

crypto.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ func GenerateX25519KeyPair() (privateKey, publicKey [32]byte, err error) {
2323
}
2424

2525
// Derive public key from private key
26-
curve25519.ScalarBaseMult(&publicKey, &privateKey)
26+
pub, err := curve25519.X25519(privateKey[:], curve25519.Basepoint)
27+
if err != nil {
28+
return privateKey, publicKey, fmt.Errorf("failed to derive public key: %w", err)
29+
}
30+
copy(publicKey[:], pub)
2731
return privateKey, publicKey, nil
2832
}
2933

crypto_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package main
2+
3+
import (
4+
"encoding/hex"
5+
"testing"
6+
)
7+
8+
func TestGenerateX25519KeyPair(t *testing.T) {
9+
priv, pub, err := GenerateX25519KeyPair()
10+
if err != nil {
11+
t.Fatalf("GenerateX25519KeyPair failed: %v", err)
12+
}
13+
14+
allZero := true
15+
for _, b := range priv {
16+
if b != 0 {
17+
allZero = false
18+
break
19+
}
20+
}
21+
if allZero {
22+
t.Error("private key is all zeros")
23+
}
24+
25+
allZero = true
26+
for _, b := range pub {
27+
if b != 0 {
28+
allZero = false
29+
break
30+
}
31+
}
32+
if allZero {
33+
t.Error("public key is all zeros")
34+
}
35+
}
36+
37+
func TestGenerateX25519KeyPairUniqueness(t *testing.T) {
38+
_, pub1, _ := GenerateX25519KeyPair()
39+
_, pub2, _ := GenerateX25519KeyPair()
40+
41+
if pub1 == pub2 {
42+
t.Error("expected different key pairs")
43+
}
44+
}
45+
46+
func TestEncryptForMobile(t *testing.T) {
47+
pcPriv, _, err := GenerateX25519KeyPair()
48+
if err != nil {
49+
t.Fatalf("generate PC keypair failed: %v", err)
50+
}
51+
52+
_, mobilePub, err := GenerateX25519KeyPair()
53+
if err != nil {
54+
t.Fatalf("generate mobile keypair failed: %v", err)
55+
}
56+
57+
mobilePubHex := hex.EncodeToString(mobilePub[:])
58+
59+
encrypted, err := EncryptForMobile("test-token", mobilePubHex, pcPriv)
60+
if err != nil {
61+
t.Fatalf("EncryptForMobile failed: %v", err)
62+
}
63+
64+
if len(encrypted) == 0 {
65+
t.Error("encrypted output is empty")
66+
}
67+
68+
_, err = hex.DecodeString(encrypted)
69+
if err != nil {
70+
t.Errorf("encrypted output is not valid hex: %v", err)
71+
}
72+
}
73+
74+
func TestEncryptForMobileInvalidKey(t *testing.T) {
75+
pcPriv, _, _ := GenerateX25519KeyPair()
76+
77+
_, err := EncryptForMobile("test", "not-hex", pcPriv)
78+
if err == nil {
79+
t.Error("expected error for invalid hex key")
80+
}
81+
82+
_, err = EncryptForMobile("test", "abcd", pcPriv)
83+
if err == nil {
84+
t.Error("expected error for wrong-length key")
85+
}
86+
}
87+
88+
func TestGetPrivateKeyFromHex(t *testing.T) {
89+
priv, _, _ := GenerateX25519KeyPair()
90+
hexKey := hex.EncodeToString(priv[:])
91+
92+
recovered, err := GetPrivateKeyFromHex(hexKey)
93+
if err != nil {
94+
t.Fatalf("GetPrivateKeyFromHex failed: %v", err)
95+
}
96+
97+
if recovered != priv {
98+
t.Error("recovered key does not match original")
99+
}
100+
}
101+
102+
func TestGetPrivateKeyFromHexInvalid(t *testing.T) {
103+
_, err := GetPrivateKeyFromHex("not-hex")
104+
if err == nil {
105+
t.Error("expected error for invalid hex")
106+
}
107+
108+
_, err = GetPrivateKeyFromHex("abcd")
109+
if err == nil {
110+
t.Error("expected error for wrong-length key")
111+
}
112+
}

encryption_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func setupTestDaemon(token string) *Daemon {
8+
d := &Daemon{token: token}
9+
d.initEncryption()
10+
return d
11+
}
12+
13+
func TestEncryptDecryptRoundtrip(t *testing.T) {
14+
d := setupTestDaemon("test-token-32-chars-long-enough!")
15+
16+
plaintext := []byte("Hello, mobile!")
17+
encrypted, err := d.encrypt(plaintext)
18+
if err != nil {
19+
t.Fatalf("encrypt failed: %v", err)
20+
}
21+
22+
decrypted, err := d.decrypt(encrypted)
23+
if err != nil {
24+
t.Fatalf("decrypt failed: %v", err)
25+
}
26+
27+
if string(decrypted) != string(plaintext) {
28+
t.Errorf("roundtrip failed: got %q, want %q", decrypted, plaintext)
29+
}
30+
}
31+
32+
func TestDecryptWithWrongKey(t *testing.T) {
33+
d1 := setupTestDaemon("token-one")
34+
d2 := setupTestDaemon("token-two")
35+
36+
encrypted, err := d1.encrypt([]byte("secret"))
37+
if err != nil {
38+
t.Fatalf("encrypt failed: %v", err)
39+
}
40+
41+
_, err = d2.decrypt(encrypted)
42+
if err == nil {
43+
t.Error("expected decryption to fail with wrong key")
44+
}
45+
}
46+
47+
func TestEncryptNotInitialized(t *testing.T) {
48+
d := &Daemon{}
49+
_, err := d.encrypt([]byte("data"))
50+
if err == nil {
51+
t.Error("expected error when encryption not initialized")
52+
}
53+
}
54+
55+
func TestDecryptNotInitialized(t *testing.T) {
56+
d := &Daemon{}
57+
_, err := d.decrypt("dGVzdA==")
58+
if err == nil {
59+
t.Error("expected error when encryption not initialized")
60+
}
61+
}
62+
63+
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
64+
d := setupTestDaemon("test-token")
65+
plaintext := []byte("same data")
66+
67+
e1, err := d.encrypt(plaintext)
68+
if err != nil {
69+
t.Fatalf("first encrypt failed: %v", err)
70+
}
71+
72+
e2, err := d.encrypt(plaintext)
73+
if err != nil {
74+
t.Fatalf("second encrypt failed: %v", err)
75+
}
76+
77+
if e1 == e2 {
78+
t.Error("expected different ciphertexts due to random nonce")
79+
}
80+
}
81+
82+
func TestEncryptDecryptEmptyData(t *testing.T) {
83+
d := setupTestDaemon("test-token")
84+
85+
encrypted, err := d.encrypt([]byte{})
86+
if err != nil {
87+
t.Fatalf("encrypt empty failed: %v", err)
88+
}
89+
90+
decrypted, err := d.decrypt(encrypted)
91+
if err != nil {
92+
t.Fatalf("decrypt empty failed: %v", err)
93+
}
94+
95+
if len(decrypted) != 0 {
96+
t.Errorf("expected empty result, got %d bytes", len(decrypted))
97+
}
98+
}
99+
100+
func TestEncryptDecryptLargeData(t *testing.T) {
101+
d := setupTestDaemon("test-token")
102+
103+
plaintext := make([]byte, 1024*1024) // 1MB
104+
for i := range plaintext {
105+
plaintext[i] = byte(i % 256)
106+
}
107+
108+
encrypted, err := d.encrypt(plaintext)
109+
if err != nil {
110+
t.Fatalf("encrypt large failed: %v", err)
111+
}
112+
113+
decrypted, err := d.decrypt(encrypted)
114+
if err != nil {
115+
t.Fatalf("decrypt large failed: %v", err)
116+
}
117+
118+
if len(decrypted) != len(plaintext) {
119+
t.Fatalf("size mismatch: got %d, want %d", len(decrypted), len(plaintext))
120+
}
121+
for i := range plaintext {
122+
if decrypted[i] != plaintext[i] {
123+
t.Fatalf("data mismatch at byte %d", i)
124+
}
125+
}
126+
}

go.mod

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
module github.com/softwarity/aipilot-cli
22

3-
go 1.20
3+
go 1.25.0
44

55
require (
66
github.com/aymanbagabas/go-pty v0.2.2
77
github.com/google/uuid v1.6.0
88
github.com/gorilla/websocket v1.5.3
99
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
10-
golang.org/x/crypto v0.17.0
11-
golang.org/x/term v0.27.0
10+
golang.org/x/crypto v0.49.0
11+
golang.org/x/term v0.41.0
1212
)
1313

1414
require (
1515
github.com/creack/pty v1.1.21 // indirect
1616
github.com/u-root/u-root v0.11.0 // indirect
17-
golang.org/x/sys v0.28.0 // indirect
17+
golang.org/x/sys v0.42.0 // indirect
1818
)

go.sum

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
99
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
1010
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
1111
github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90 h1:zTk5683I9K62wtZ6eUa6vu6IWwVHXPnoKK5n2unAwv0=
12+
github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90/go.mod h1:lYt+LVfZBBwDZ3+PHk4k/c/TnKOkjJXiJO73E32Mmpc=
1213
github.com/u-root/u-root v0.11.0 h1:6gCZLOeRyevw7gbTwMj3fKxnr9+yHFlgF3N7udUVNO8=
1314
github.com/u-root/u-root v0.11.0/go.mod h1:DBkDtiZyONk9hzVEdB/PWI9B4TxDkElWlVTHseglrZY=
14-
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
15-
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
16-
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
17-
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
18-
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
19-
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
15+
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
16+
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
17+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
18+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
19+
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
20+
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=

pairing.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ type PairedMobile struct {
2121

2222
// PCConfig represents the PC's identity and paired devices
2323
type PCConfig struct {
24-
PCID string `json:"pc_id"`
25-
PCName string `json:"pc_name"`
26-
PrivateKey string `json:"private_key"`
27-
PublicKey string `json:"public_key"`
24+
PCID string `json:"pc_id"`
25+
PCName string `json:"pc_name"`
26+
PrivateKey string `json:"private_key"`
27+
PublicKey string `json:"public_key"`
28+
Secret string `json:"secret,omitempty"`
2829
PairedMobiles []PairedMobile `json:"paired_mobiles"`
29-
CreatedAt string `json:"created_at"`
30+
CreatedAt string `json:"created_at"`
3031
}
3132

3233
// DirectoryConfig represents remembered agent choice per directory

0 commit comments

Comments
 (0)