test: add UEPS wire protocol tests (Phase 1) — 0% to 88.5% coverage
25 subtests covering all 9 TODO items: round-trip, HMAC tampering (payload + header), wrong shared secret, empty payload, ThreatScore uint16 boundary, missing HMAC tag, TLV overflow, truncated packets, and unknown tag handling. Also adds KB wiki docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6d71da4065
commit
2bc53bac61
2 changed files with 420 additions and 0 deletions
1
KB
Submodule
1
KB
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 4dcd7ea9fe427e5ed2eba73353f9a476fe7d9324
|
||||||
419
ueps/packet_test.go
Normal file
419
ueps/packet_test.go
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
package ueps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSecret is a deterministic shared secret for reproducible tests.
|
||||||
|
var testSecret = []byte("test-shared-secret-32-bytes!!!!!")
|
||||||
|
|
||||||
|
func TestPacketBuilder_RoundTrip(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
intentID uint8
|
||||||
|
payload []byte
|
||||||
|
threatScore uint16
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "BasicPayload",
|
||||||
|
intentID: 0x20,
|
||||||
|
payload: []byte("hello UEPS"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BinaryPayload",
|
||||||
|
intentID: 0x01,
|
||||||
|
payload: []byte{0x00, 0xFF, 0xDE, 0xAD, 0xBE, 0xEF},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "WithThreatScore",
|
||||||
|
intentID: 0x30,
|
||||||
|
payload: []byte("threat test"),
|
||||||
|
threatScore: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LargePayload",
|
||||||
|
intentID: 0xFF,
|
||||||
|
payload: bytes.Repeat([]byte("A"), 4096),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
builder := NewBuilder(tc.intentID, tc.payload)
|
||||||
|
builder.Header.ThreatScore = tc.threatScore
|
||||||
|
|
||||||
|
frame, err := builder.MarshalAndSign(testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalAndSign failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := ReadAndVerify(bufio.NewReader(bytes.NewReader(frame)), testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAndVerify failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all header fields round-trip correctly
|
||||||
|
if parsed.Header.Version != 0x09 {
|
||||||
|
t.Errorf("Version: got 0x%02x, want 0x09", parsed.Header.Version)
|
||||||
|
}
|
||||||
|
if parsed.Header.CurrentLayer != 5 {
|
||||||
|
t.Errorf("CurrentLayer: got %d, want 5", parsed.Header.CurrentLayer)
|
||||||
|
}
|
||||||
|
if parsed.Header.TargetLayer != 5 {
|
||||||
|
t.Errorf("TargetLayer: got %d, want 5", parsed.Header.TargetLayer)
|
||||||
|
}
|
||||||
|
if parsed.Header.IntentID != tc.intentID {
|
||||||
|
t.Errorf("IntentID: got 0x%02x, want 0x%02x", parsed.Header.IntentID, tc.intentID)
|
||||||
|
}
|
||||||
|
if parsed.Header.ThreatScore != tc.threatScore {
|
||||||
|
t.Errorf("ThreatScore: got %d, want %d", parsed.Header.ThreatScore, tc.threatScore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify payload integrity
|
||||||
|
if !bytes.Equal(parsed.Payload, tc.payload) {
|
||||||
|
t.Errorf("Payload mismatch: got %d bytes, want %d bytes", len(parsed.Payload), len(tc.payload))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACVerification_TamperedPayload(t *testing.T) {
|
||||||
|
builder := NewBuilder(0x20, []byte("original payload"))
|
||||||
|
frame, err := builder.MarshalAndSign(testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalAndSign failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip the last byte of the frame (which is in the payload)
|
||||||
|
tampered := make([]byte, len(frame))
|
||||||
|
copy(tampered, frame)
|
||||||
|
tampered[len(tampered)-1] ^= 0xFF
|
||||||
|
|
||||||
|
_, err = ReadAndVerify(bufio.NewReader(bytes.NewReader(tampered)), testSecret)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected HMAC mismatch error for tampered payload")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "integrity violation") {
|
||||||
|
t.Errorf("Expected integrity violation error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACVerification_TamperedHeader(t *testing.T) {
|
||||||
|
builder := NewBuilder(0x20, []byte("test payload"))
|
||||||
|
frame, err := builder.MarshalAndSign(testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalAndSign failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tamper with the Version TLV value (byte index 2: tag=0, len=1, val=2)
|
||||||
|
tampered := make([]byte, len(frame))
|
||||||
|
copy(tampered, frame)
|
||||||
|
tampered[2] = 0x01 // Change version from 0x09 to 0x01
|
||||||
|
|
||||||
|
_, err = ReadAndVerify(bufio.NewReader(bytes.NewReader(tampered)), testSecret)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected HMAC mismatch error for tampered header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "integrity violation") {
|
||||||
|
t.Errorf("Expected integrity violation error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACVerification_WrongSharedSecret(t *testing.T) {
|
||||||
|
builder := NewBuilder(0x20, []byte("secret data"))
|
||||||
|
frame, err := builder.MarshalAndSign([]byte("key-A-used-for-signing!!!!!!!!!!"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalAndSign failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ReadAndVerify(bufio.NewReader(bytes.NewReader(frame)), []byte("key-B-used-for-reading!!!!!!!!!!"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected HMAC mismatch error for wrong shared secret")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "integrity violation") {
|
||||||
|
t.Errorf("Expected integrity violation error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyPayload(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
payload []byte
|
||||||
|
}{
|
||||||
|
{"NilPayload", nil},
|
||||||
|
{"EmptySlice", []byte{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
builder := NewBuilder(0x01, tc.payload)
|
||||||
|
frame, err := builder.MarshalAndSign(testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalAndSign failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := ReadAndVerify(bufio.NewReader(bytes.NewReader(frame)), testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAndVerify failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parsed.Payload) != 0 {
|
||||||
|
t.Errorf("Expected empty payload, got %d bytes", len(parsed.Payload))
|
||||||
|
}
|
||||||
|
if parsed.Header.IntentID != 0x01 {
|
||||||
|
t.Errorf("IntentID: got 0x%02x, want 0x01", parsed.Header.IntentID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaxThreatScoreBoundary(t *testing.T) {
|
||||||
|
builder := NewBuilder(0x20, []byte("threat boundary"))
|
||||||
|
builder.Header.ThreatScore = 65535 // uint16 max
|
||||||
|
|
||||||
|
frame, err := builder.MarshalAndSign(testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalAndSign failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := ReadAndVerify(bufio.NewReader(bytes.NewReader(frame)), testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAndVerify failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Header.ThreatScore != 65535 {
|
||||||
|
t.Errorf("ThreatScore: got %d, want 65535", parsed.Header.ThreatScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingHMACTag(t *testing.T) {
|
||||||
|
// Craft a packet manually: header TLVs + payload tag, but no HMAC (0x06)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Write header TLVs
|
||||||
|
writeTLV(&buf, TagVersion, []byte{0x09})
|
||||||
|
writeTLV(&buf, TagCurrentLay, []byte{5})
|
||||||
|
writeTLV(&buf, TagTargetLay, []byte{5})
|
||||||
|
writeTLV(&buf, TagIntent, []byte{0x20})
|
||||||
|
tsBuf := make([]byte, 2)
|
||||||
|
binary.BigEndian.PutUint16(tsBuf, 0)
|
||||||
|
writeTLV(&buf, TagThreatScore, tsBuf)
|
||||||
|
|
||||||
|
// Skip HMAC TLV entirely — go straight to payload
|
||||||
|
buf.WriteByte(TagPayload)
|
||||||
|
buf.Write([]byte("some data"))
|
||||||
|
|
||||||
|
_, err := ReadAndVerify(bufio.NewReader(bytes.NewReader(buf.Bytes())), testSecret)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected 'missing HMAC' error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing HMAC") {
|
||||||
|
t.Errorf("Expected 'missing HMAC' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTLV_ValueTooLarge(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
oversized := make([]byte, 256) // 1 byte over the 255 limit
|
||||||
|
err := writeTLV(&buf, TagVersion, oversized)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for TLV value > 255 bytes")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "TLV value too large") {
|
||||||
|
t.Errorf("Expected 'TLV value too large' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncatedPacket(t *testing.T) {
|
||||||
|
builder := NewBuilder(0x20, []byte("full payload"))
|
||||||
|
frame, err := builder.MarshalAndSign(testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalAndSign failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cutAt int
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CutInFirstTLVHeader",
|
||||||
|
cutAt: 1, // Only tag byte, no length
|
||||||
|
wantErr: "EOF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CutInFirstTLVValue",
|
||||||
|
cutAt: 2, // Tag + length, but missing value
|
||||||
|
wantErr: "EOF",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CutMidHMAC",
|
||||||
|
cutAt: 20, // Somewhere inside the header TLVs or HMAC
|
||||||
|
wantErr: "", // Any io error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
truncated := frame[:tc.cutAt]
|
||||||
|
_, err := ReadAndVerify(bufio.NewReader(bytes.NewReader(truncated)), testSecret)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for truncated packet")
|
||||||
|
}
|
||||||
|
if tc.wantErr != "" && !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Errorf("Expected error containing %q, got: %v", tc.wantErr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownTLVTag(t *testing.T) {
|
||||||
|
// Build a valid packet, then inject an unknown tag before the HMAC.
|
||||||
|
// The unknown tag must be included in signedData for HMAC to pass.
|
||||||
|
payload := []byte("tagged payload")
|
||||||
|
|
||||||
|
// Manually construct headers + unknown tag + HMAC + payload
|
||||||
|
var headerBuf bytes.Buffer
|
||||||
|
|
||||||
|
// Standard header TLVs
|
||||||
|
writeTLV(&headerBuf, TagVersion, []byte{0x09})
|
||||||
|
writeTLV(&headerBuf, TagCurrentLay, []byte{5})
|
||||||
|
writeTLV(&headerBuf, TagTargetLay, []byte{5})
|
||||||
|
writeTLV(&headerBuf, TagIntent, []byte{0x20})
|
||||||
|
tsBuf := make([]byte, 2)
|
||||||
|
binary.BigEndian.PutUint16(tsBuf, 0)
|
||||||
|
writeTLV(&headerBuf, TagThreatScore, tsBuf)
|
||||||
|
|
||||||
|
// Unknown tag (0xAA) with some data
|
||||||
|
unknownValue := []byte{0xDE, 0xAD}
|
||||||
|
writeTLV(&headerBuf, 0xAA, unknownValue)
|
||||||
|
|
||||||
|
// Compute HMAC over (all header TLVs including unknown + payload)
|
||||||
|
mac := hmac.New(sha256.New, testSecret)
|
||||||
|
mac.Write(headerBuf.Bytes())
|
||||||
|
mac.Write(payload)
|
||||||
|
signature := mac.Sum(nil)
|
||||||
|
|
||||||
|
// Assemble full frame: headers + unknown + HMAC TLV + 0xFF + payload
|
||||||
|
var frame bytes.Buffer
|
||||||
|
frame.Write(headerBuf.Bytes())
|
||||||
|
writeTLV(&frame, TagHMAC, signature)
|
||||||
|
frame.WriteByte(TagPayload)
|
||||||
|
frame.Write(payload)
|
||||||
|
|
||||||
|
parsed, err := ReadAndVerify(bufio.NewReader(bytes.NewReader(frame.Bytes())), testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAndVerify should accept unknown tag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header fields should still be correct
|
||||||
|
if parsed.Header.Version != 0x09 {
|
||||||
|
t.Errorf("Version: got 0x%02x, want 0x09", parsed.Header.Version)
|
||||||
|
}
|
||||||
|
if parsed.Header.IntentID != 0x20 {
|
||||||
|
t.Errorf("IntentID: got 0x%02x, want 0x20", parsed.Header.IntentID)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(parsed.Payload, payload) {
|
||||||
|
t.Errorf("Payload mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewBuilder_Defaults(t *testing.T) {
|
||||||
|
builder := NewBuilder(0x20, []byte("data"))
|
||||||
|
|
||||||
|
if builder.Header.Version != 0x09 {
|
||||||
|
t.Errorf("Default Version: got 0x%02x, want 0x09", builder.Header.Version)
|
||||||
|
}
|
||||||
|
if builder.Header.CurrentLayer != 5 {
|
||||||
|
t.Errorf("Default CurrentLayer: got %d, want 5", builder.Header.CurrentLayer)
|
||||||
|
}
|
||||||
|
if builder.Header.TargetLayer != 5 {
|
||||||
|
t.Errorf("Default TargetLayer: got %d, want 5", builder.Header.TargetLayer)
|
||||||
|
}
|
||||||
|
if builder.Header.ThreatScore != 0 {
|
||||||
|
t.Errorf("Default ThreatScore: got %d, want 0", builder.Header.ThreatScore)
|
||||||
|
}
|
||||||
|
if builder.Header.IntentID != 0x20 {
|
||||||
|
t.Errorf("IntentID: got 0x%02x, want 0x20", builder.Header.IntentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThreatScoreBoundaries(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
score uint16
|
||||||
|
}{
|
||||||
|
{"Zero", 0},
|
||||||
|
{"One", 1},
|
||||||
|
{"Mid", 32768},
|
||||||
|
{"Max", 65535},
|
||||||
|
{"HighThreat", 50001},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
builder := NewBuilder(0x20, []byte("score test"))
|
||||||
|
builder.Header.ThreatScore = tc.score
|
||||||
|
|
||||||
|
frame, err := builder.MarshalAndSign(testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalAndSign failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := ReadAndVerify(bufio.NewReader(bytes.NewReader(frame)), testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAndVerify failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Header.ThreatScore != tc.score {
|
||||||
|
t.Errorf("ThreatScore: got %d, want %d", parsed.Header.ThreatScore, tc.score)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTLV_BoundaryLengths(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
length int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"Empty", 0, false},
|
||||||
|
{"OneByte", 1, false},
|
||||||
|
{"MaxValid", 255, false},
|
||||||
|
{"OneOver", 256, true},
|
||||||
|
{"WayOver", 1024, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
value := make([]byte, tc.length)
|
||||||
|
err := writeTLV(&buf, 0x01, value)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Error("Expected error for oversized TLV value")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadAndVerify_EmptyReader verifies behaviour on completely empty input.
|
||||||
|
func TestReadAndVerify_EmptyReader(t *testing.T) {
|
||||||
|
_, err := ReadAndVerify(bufio.NewReader(bytes.NewReader(nil)), testSecret)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for empty reader")
|
||||||
|
}
|
||||||
|
if err != io.EOF {
|
||||||
|
t.Errorf("Expected io.EOF, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue