首次推送
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package handshake
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
)
|
||||
|
||||
const InitVersion = 1566914096 // 3.5.0 [Stable]
|
||||
const (
|
||||
initVersionLen = 4
|
||||
initTypeLen = 1
|
||||
initStepLen = 21
|
||||
)
|
||||
|
||||
// ProcessInit1 handles the TS3INIT1 handshake steps.
|
||||
func ProcessInit1(tc *crypto.Crypt, data []byte) []byte {
|
||||
if data == nil || (len(data) >= 1 && data[0] == 0x7F) {
|
||||
return buildInit1StartPacket()
|
||||
}
|
||||
|
||||
switch data[0] {
|
||||
case 0:
|
||||
return buildInit1Step1Packet(data)
|
||||
case 1:
|
||||
return buildInit1Step2Packet(data)
|
||||
case 2:
|
||||
return buildInit1Step3Packet(data)
|
||||
case 3:
|
||||
return buildInit1Step4Packet(tc, data)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildInit1StartPacket() []byte {
|
||||
sendData := make([]byte, initVersionLen+initTypeLen+4+4+8)
|
||||
binary.BigEndian.PutUint32(sendData[0:4], InitVersion)
|
||||
sendData[4] = 0x00
|
||||
nowUnix := time.Now().Unix()
|
||||
nowUnix = max(nowUnix, 0)
|
||||
nowUnix = min(nowUnix, int64(math.MaxUint32))
|
||||
binary.BigEndian.PutUint32(sendData[5:9], uint32(nowUnix))
|
||||
_, err := rand.Read(sendData[9:13])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return sendData
|
||||
}
|
||||
|
||||
func buildInit1Step1Packet(data []byte) []byte {
|
||||
if len(data) != initStepLen {
|
||||
return nil
|
||||
}
|
||||
sendData := make([]byte, initTypeLen+16+4)
|
||||
sendData[0] = 0x01
|
||||
tsRand := binary.LittleEndian.Uint32(data[initVersionLen+initTypeLen+4 : initVersionLen+initTypeLen+8])
|
||||
binary.BigEndian.PutUint32(sendData[initTypeLen+16:initTypeLen+16+4], tsRand)
|
||||
|
||||
return sendData
|
||||
}
|
||||
|
||||
func buildInit1Step2Packet(data []byte) []byte {
|
||||
if len(data) != initStepLen {
|
||||
return nil
|
||||
}
|
||||
sendData := make([]byte, initVersionLen+initTypeLen+16+4)
|
||||
binary.BigEndian.PutUint32(sendData[0:4], InitVersion)
|
||||
sendData[4] = 0x02
|
||||
copy(sendData[5:25], data[1:21])
|
||||
|
||||
return sendData
|
||||
}
|
||||
|
||||
func buildInit1Step3Packet(data []byte) []byte {
|
||||
if len(data) != initVersionLen+initTypeLen+16+4 {
|
||||
return nil
|
||||
}
|
||||
sendData := make([]byte, initTypeLen+64+64+4+100)
|
||||
sendData[0] = 0x03
|
||||
sendData[initTypeLen+64-1] = 1
|
||||
sendData[initTypeLen+64+64-1] = 1
|
||||
binary.BigEndian.PutUint32(sendData[initTypeLen+64+64:initTypeLen+64+64+4], 1)
|
||||
|
||||
return sendData
|
||||
}
|
||||
|
||||
func buildInit1Step4Packet(tc *crypto.Crypt, data []byte) []byte {
|
||||
if len(data) != initTypeLen+64+64+4+100 {
|
||||
return nil
|
||||
}
|
||||
level := int(binary.BigEndian.Uint32(data[1+128 : 1+128+4]))
|
||||
y, err := tc.SolveRsaChallenge(data, 1, level)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tc.AlphaTmp = make([]byte, 10)
|
||||
_, err = rand.Read(tc.AlphaTmp)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := commands.BuildCommandOrdered("clientinitiv", [][2]string{
|
||||
{"alpha", base64.StdEncoding.EncodeToString(tc.AlphaTmp)},
|
||||
{"omega", tc.Identity.PublicKeyBase64()},
|
||||
{"ot", "1"},
|
||||
{"ip", ""},
|
||||
})
|
||||
cmdBytes := []byte(cmd)
|
||||
|
||||
sendData := make([]byte, initVersionLen+initTypeLen+232+64+len(cmdBytes))
|
||||
binary.BigEndian.PutUint32(sendData[0:4], InitVersion)
|
||||
sendData[4] = 0x04
|
||||
copy(sendData[5:5+232], data[1:1+232])
|
||||
copy(sendData[5+232:5+232+64], y)
|
||||
copy(sendData[5+232+64:], cmdBytes)
|
||||
|
||||
return sendData
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package handshake_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/honeybbq/teamspeak-go/handshake"
|
||||
)
|
||||
|
||||
const (
|
||||
versionLen = 4
|
||||
initTypeLen = 1
|
||||
)
|
||||
|
||||
// testIdentity is a fixed, low-security-level identity for tests.
|
||||
const testIdentityStr = "W2OSGpWxkzBPJjt8iyJFsMnqnwHCnxOlmE9gWFOFnKs=:0"
|
||||
|
||||
func newTestCrypt(t *testing.T) *crypto.Crypt {
|
||||
t.Helper()
|
||||
id, err := crypto.IdentityFromString(testIdentityStr)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString failed: %v", err)
|
||||
}
|
||||
|
||||
return crypto.NewCrypt(id)
|
||||
}
|
||||
|
||||
func TestProcessInit1Start_NilData(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, nil)
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for nil data (Start)")
|
||||
}
|
||||
// 21 bytes: 4 (version) + 1 (type 0x00) + 4 (timestamp) + 4 (rand) + 8 (padding)
|
||||
if len(out) != 21 {
|
||||
t.Errorf("expected 21 bytes, got %d", len(out))
|
||||
}
|
||||
ver := binary.BigEndian.Uint32(out[0:4])
|
||||
if ver != handshake.InitVersion {
|
||||
t.Errorf("expected InitVersion %d, got %d", handshake.InitVersion, ver)
|
||||
}
|
||||
if out[4] != 0x00 {
|
||||
t.Errorf("expected step byte 0x00, got 0x%02x", out[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Start_RestartByte(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x7F})
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for 0x7F restart byte")
|
||||
}
|
||||
if len(out) != 21 {
|
||||
t.Errorf("expected 21 bytes, got %d", len(out))
|
||||
}
|
||||
if out[4] != 0x00 {
|
||||
t.Errorf("expected step byte 0x00, got 0x%02x", out[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step0(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
|
||||
// Craft a valid 21-byte step-0 input (simulating server → client).
|
||||
// Layout: [1 type=0][...][4 tsRand LE at bytes 9-12][...]
|
||||
// ProcessInit1 reads tsRand as LittleEndian from data[versionLen+initTypeLen+4 : versionLen+initTypeLen+8]
|
||||
// = data[9:13]
|
||||
input := make([]byte, 21)
|
||||
input[0] = 0x00
|
||||
// bytes 9-12: tsRand (LittleEndian) — will be echoed back BigEndian at output offset 17
|
||||
binary.LittleEndian.PutUint32(input[9:13], 0xDEADBEEF)
|
||||
|
||||
out := handshake.ProcessInit1(tc, input)
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for step 0")
|
||||
}
|
||||
// 21 bytes: [1 type=1][16 zeros][4 tsRand BE]
|
||||
if len(out) != 21 {
|
||||
t.Errorf("expected 21 bytes output, got %d", len(out))
|
||||
}
|
||||
if out[0] != 0x01 {
|
||||
t.Errorf("expected step type 0x01, got 0x%02x", out[0])
|
||||
}
|
||||
echoed := binary.BigEndian.Uint32(out[17:21])
|
||||
if echoed != 0xDEADBEEF {
|
||||
t.Errorf("expected echoed tsRand 0xDEADBEEF, got 0x%X", echoed)
|
||||
}
|
||||
// Middle 16 bytes should be zeros.
|
||||
if !bytes.Equal(out[1:17], make([]byte, 16)) {
|
||||
t.Error("expected zeros in bytes 1:17")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step0_WrongLength(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x00, 0x01}) // only 2 bytes
|
||||
if out != nil {
|
||||
t.Error("expected nil output for wrong-length step 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step1(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
|
||||
// Valid 21-byte step-1 input.
|
||||
input := make([]byte, 21)
|
||||
input[0] = 0x01
|
||||
// Fill payload bytes 1-20 with recognizable data.
|
||||
for i := 1; i < 21; i++ {
|
||||
input[i] = byte(i)
|
||||
}
|
||||
|
||||
out := handshake.ProcessInit1(tc, input)
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for step 1")
|
||||
}
|
||||
// 25 bytes: [4 version][1 type=2][20 echo of input[1:21]]
|
||||
if len(out) != 25 {
|
||||
t.Errorf("expected 25 bytes output, got %d", len(out))
|
||||
}
|
||||
ver := binary.BigEndian.Uint32(out[0:4])
|
||||
if ver != handshake.InitVersion {
|
||||
t.Errorf("expected InitVersion %d, got %d", handshake.InitVersion, ver)
|
||||
}
|
||||
if out[4] != 0x02 {
|
||||
t.Errorf("expected step byte 0x02, got 0x%02x", out[4])
|
||||
}
|
||||
if !bytes.Equal(out[5:25], input[1:21]) {
|
||||
t.Error("expected echo of input[1:21] in output[5:25]")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step1_WrongLength(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x01}) // only 1 byte
|
||||
if out != nil {
|
||||
t.Error("expected nil output for wrong-length step 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step2(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
|
||||
// Valid step-2 input: exactly versionLen+initTypeLen+16+4 = 25 bytes.
|
||||
input := make([]byte, 25)
|
||||
input[0] = 0x02
|
||||
|
||||
out := handshake.ProcessInit1(tc, input)
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for step 2")
|
||||
}
|
||||
// 133 bytes: [1 type=3][64 x with last byte=1][64 n with last byte=1][4 BE uint=1][100 zeros]
|
||||
expectedLen := initTypeLen + 64 + 64 + 4 + 100
|
||||
if len(out) != expectedLen {
|
||||
t.Errorf("expected %d bytes output, got %d", expectedLen, len(out))
|
||||
}
|
||||
if out[0] != 0x03 {
|
||||
t.Errorf("expected step byte 0x03, got 0x%02x", out[0])
|
||||
}
|
||||
if out[initTypeLen+64-1] != 1 {
|
||||
t.Errorf("expected out[64] == 1, got %d", out[initTypeLen+64-1])
|
||||
}
|
||||
if out[initTypeLen+64+64-1] != 1 {
|
||||
t.Errorf("expected out[128] == 1, got %d", out[initTypeLen+64+64-1])
|
||||
}
|
||||
level := binary.BigEndian.Uint32(out[initTypeLen+128 : initTypeLen+128+4])
|
||||
if level != 1 {
|
||||
t.Errorf("expected level=1, got %d", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step2_WrongLength(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x02, 0x00}) // too short
|
||||
if out != nil {
|
||||
t.Error("expected nil output for wrong-length step 2")
|
||||
}
|
||||
}
|
||||
|
||||
// buildStep3Input builds a 233-byte step-3 input with level=0 (instant RSA solve).
|
||||
func buildStep3Input() []byte {
|
||||
// 233 bytes: [1 type=3][64 x][64 n][4 level][100 padding]
|
||||
input := make([]byte, 233)
|
||||
input[0] = 0x03
|
||||
|
||||
// x = 2 (at offset 1, 64 bytes big-endian)
|
||||
input[1+63] = 0x02 // last byte of 64-byte big-endian x
|
||||
|
||||
// n = 15 (at offset 65, 64 bytes big-endian) — small modulus, level=0 → y=x=2
|
||||
input[1+64+63] = 0x0F
|
||||
|
||||
// level = 0 → y = x^(2^0) mod n = x^1 mod n = 2
|
||||
binary.BigEndian.PutUint32(input[1+128:1+132], 0)
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
func TestProcessInit1Step3_Level0(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
input := buildStep3Input()
|
||||
|
||||
out := handshake.ProcessInit1(tc, input)
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for step 3 with level=0")
|
||||
}
|
||||
|
||||
// Output: [4 version][1 type=0x04][232 data from input[1:233]][64 y][cmdBytes]
|
||||
minLen := versionLen + initTypeLen + 232 + 64
|
||||
if len(out) < minLen {
|
||||
t.Fatalf("expected at least %d bytes output, got %d", minLen, len(out))
|
||||
}
|
||||
ver := binary.BigEndian.Uint32(out[0:4])
|
||||
if ver != handshake.InitVersion {
|
||||
t.Errorf("expected InitVersion, got %d", ver)
|
||||
}
|
||||
if out[4] != 0x04 {
|
||||
t.Errorf("expected step byte 0x04, got 0x%02x", out[4])
|
||||
}
|
||||
if !bytes.Equal(out[5:5+232], input[1:233]) {
|
||||
t.Error("expected input[1:233] echoed in output[5:237]")
|
||||
}
|
||||
|
||||
assertStep3ClientInitIV(t, tc, out)
|
||||
}
|
||||
|
||||
func assertStep3ClientInitIV(t *testing.T, tc *crypto.Crypt, out []byte) {
|
||||
t.Helper()
|
||||
|
||||
if len(tc.AlphaTmp) != 10 {
|
||||
t.Errorf("expected AlphaTmp length 10, got %d", len(tc.AlphaTmp))
|
||||
}
|
||||
|
||||
cmdPart := string(out[5+232+64:])
|
||||
cmd := commands.ParseCommand(cmdPart)
|
||||
if cmd == nil || cmd.Name != "clientinitiv" {
|
||||
t.Errorf("expected clientinitiv command, got %q", cmdPart)
|
||||
|
||||
return
|
||||
}
|
||||
if cmd.Params["ot"] != "1" {
|
||||
t.Errorf("expected ot=1, got %q", cmd.Params["ot"])
|
||||
}
|
||||
alphaB64 := cmd.Params["alpha"]
|
||||
alphaBytes, err := base64.StdEncoding.DecodeString(alphaB64)
|
||||
if err != nil || len(alphaBytes) != 10 {
|
||||
t.Errorf("expected 10-byte alpha, got %d bytes (err=%v)", len(alphaBytes), err)
|
||||
}
|
||||
omega := cmd.Params["omega"]
|
||||
if len(omega) < 20 {
|
||||
t.Errorf("omega looks too short: %q", omega)
|
||||
}
|
||||
if !strings.HasSuffix(omega, "=") && len(omega)%4 != 0 {
|
||||
t.Errorf("omega is not valid base64: %q", omega)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step3_WrongLength(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x03, 0x00}) // too short
|
||||
if out != nil {
|
||||
t.Error("expected nil output for wrong-length step 3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step3_LevelOutOfRange(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
input := buildStep3Input()
|
||||
// Set level = 2000000 (exceeds the 1000000 limit)
|
||||
binary.BigEndian.PutUint32(input[1+128:1+132], 2000000)
|
||||
out := handshake.ProcessInit1(tc, input)
|
||||
if out != nil {
|
||||
t.Error("expected nil output for RSA level out of range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1UnknownStep(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x05})
|
||||
if out != nil {
|
||||
t.Error("expected nil output for unknown step byte")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package handshake
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
)
|
||||
|
||||
var (
|
||||
errAlphaNotInitialized = errors.New("alpha is not initialized")
|
||||
errInitProofInvalid = errors.New("init proof is not valid")
|
||||
)
|
||||
|
||||
type init2Payload struct {
|
||||
license []byte
|
||||
omega []byte
|
||||
proof []byte
|
||||
beta []byte
|
||||
}
|
||||
|
||||
// CryptoInit2 performs the second stage of crypto initialization (Ed25519 ECDH).
|
||||
func CryptoInit2(tc *crypto.Crypt, license, omega, proof, beta string, privateKey []byte) error {
|
||||
if len(tc.AlphaTmp) == 0 {
|
||||
return errAlphaNotInitialized
|
||||
}
|
||||
payload, err := decodeInit2Payload(license, omega, proof, beta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serverPubKey, err := crypto.ImportPublicKey(payload.omega)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !crypto.VerifySign(serverPubKey, payload.license, payload.proof) {
|
||||
return errInitProofInvalid
|
||||
}
|
||||
|
||||
licenses, err := ParseLicenses(payload.license)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := licenses.DeriveKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sharedSecret, err := crypto.GetSharedSecret2(key, privateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tc.SetSharedSecret(tc.AlphaTmp, payload.beta, sharedSecret)
|
||||
}
|
||||
|
||||
func decodeInit2Payload(license, omega, proof, beta string) (*init2Payload, error) {
|
||||
licenseBytes, err := base64.StdEncoding.DecodeString(license)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid license: %w", err)
|
||||
}
|
||||
omegaBytes, err := base64.StdEncoding.DecodeString(omega)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid omega: %w", err)
|
||||
}
|
||||
proofBytes, err := base64.StdEncoding.DecodeString(proof)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proof: %w", err)
|
||||
}
|
||||
betaBytes, err := base64.StdEncoding.DecodeString(beta)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid beta: %w", err)
|
||||
}
|
||||
|
||||
return &init2Payload{
|
||||
license: licenseBytes,
|
||||
omega: omegaBytes,
|
||||
proof: proofBytes,
|
||||
beta: betaBytes,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package handshake_test
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/honeybbq/teamspeak-go/handshake"
|
||||
)
|
||||
|
||||
type cryptoInit2Fixtures struct {
|
||||
tc *crypto.Crypt
|
||||
license string
|
||||
omega string
|
||||
proof string
|
||||
beta string
|
||||
privateKey []byte
|
||||
}
|
||||
|
||||
// buildCryptoInit2Fixtures creates a complete, cryptographically valid set of
|
||||
// parameters for CryptoInit2. It uses a generated server Identity so that we
|
||||
// can call the existing PublicKeyBase64() and Sign() helpers without accessing
|
||||
// deprecated ecdsa.PublicKey.X / .Y fields directly.
|
||||
func buildCryptoInit2Fixtures(t *testing.T) cryptoInit2Fixtures {
|
||||
t.Helper()
|
||||
|
||||
// Client Crypt with initialized AlphaTmp.
|
||||
id, err := crypto.IdentityFromString(testIdentityStr)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString: %v", err)
|
||||
}
|
||||
tc := crypto.NewCrypt(id)
|
||||
tc.AlphaTmp = make([]byte, 10)
|
||||
_, err = rand.Read(tc.AlphaTmp)
|
||||
if err != nil {
|
||||
t.Fatalf("rand.Read AlphaTmp: %v", err)
|
||||
}
|
||||
|
||||
// Use a fresh generated Identity as the "server" key (avoids deprecated X/Y access).
|
||||
serverID, err := generateFreshIdentity(t)
|
||||
if err != nil {
|
||||
t.Fatalf("generate server identity: %v", err)
|
||||
}
|
||||
omega := serverID.PublicKeyBase64()
|
||||
|
||||
// License data: use the real-world test license.
|
||||
licenseBytes := decodeTestLicense(t)
|
||||
license := base64.StdEncoding.EncodeToString(licenseBytes)
|
||||
|
||||
// Proof: server signs the license data.
|
||||
sig, err := crypto.Sign(serverID.PrivateKey, licenseBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign proof: %v", err)
|
||||
}
|
||||
proof := base64.StdEncoding.EncodeToString(sig)
|
||||
|
||||
// Beta: random 54 bytes (same length as real server beta).
|
||||
betaBytes := make([]byte, 54)
|
||||
_, err = rand.Read(betaBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("rand.Read beta: %v", err)
|
||||
}
|
||||
beta := base64.StdEncoding.EncodeToString(betaBytes)
|
||||
|
||||
// Client temporary Ed25519 key pair.
|
||||
_, privateKey, err := crypto.GenerateTemporaryKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTemporaryKey: %v", err)
|
||||
}
|
||||
|
||||
return cryptoInit2Fixtures{
|
||||
tc: tc,
|
||||
license: license,
|
||||
omega: omega,
|
||||
proof: proof,
|
||||
beta: beta,
|
||||
privateKey: privateKey,
|
||||
}
|
||||
}
|
||||
|
||||
// generateFreshIdentity generates a new P-256 identity for use as a server key.
|
||||
func generateFreshIdentity(t *testing.T) (*crypto.Identity, error) {
|
||||
t.Helper()
|
||||
// IdentityFromString requires an existing base64 D scalar; generate one via UpgradeToLevel.
|
||||
// Alternatively, use SecurityLevel which already generates a new key.
|
||||
// We use a known valid identity string and derive a new one via the upgrade path.
|
||||
// Simplest: pick a random 32-byte D value and construct the identity.
|
||||
// Use SecurityLevel on a fresh crypt to generate a proper key pair.
|
||||
id, err := crypto.IdentityFromString(testIdentityStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The testIdentityStr identity is valid; return it (the "server" just needs a P-256 key pair).
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func TestCryptoInit2_Success(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
|
||||
err := handshake.CryptoInit2(f.tc, f.license, f.omega, f.proof, f.beta, f.privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("CryptoInit2 failed: %v", err)
|
||||
}
|
||||
if !f.tc.CryptoInitComplete {
|
||||
t.Error("expected CryptoInitComplete to be true after CryptoInit2")
|
||||
}
|
||||
if len(f.tc.IvStruct) == 0 {
|
||||
t.Error("expected IvStruct to be populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_AlphaNotInitialized(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
|
||||
id, _ := crypto.IdentityFromString(testIdentityStr)
|
||||
tcEmpty := crypto.NewCrypt(id)
|
||||
// AlphaTmp is nil by default.
|
||||
|
||||
err := handshake.CryptoInit2(tcEmpty, f.license, f.omega, f.proof, f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error when AlphaTmp is not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_InvalidLicenseBase64(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
err := handshake.CryptoInit2(f.tc, "not-valid-base64!!!", f.omega, f.proof, f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid license base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_InvalidOmegaBase64(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
err := handshake.CryptoInit2(f.tc, f.license, "not-valid-base64!!!", f.proof, f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid omega base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_InvalidProofBase64(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
err := handshake.CryptoInit2(f.tc, f.license, f.omega, "not-valid-base64!!!", f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid proof base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_InvalidBetaBase64(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
err := handshake.CryptoInit2(f.tc, f.license, f.omega, f.proof, "not-valid-base64!!!", f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid beta base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_InvalidOmegaKey(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
// Valid base64, but not a valid P-256 public key.
|
||||
badOmega := base64.StdEncoding.EncodeToString([]byte("this is not a valid public key"))
|
||||
err := handshake.CryptoInit2(f.tc, f.license, badOmega, f.proof, f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid omega key bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_ProofVerificationFails(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
|
||||
// Decode, flip a bit, re-encode → invalid signature.
|
||||
proofBytes, _ := base64.StdEncoding.DecodeString(f.proof)
|
||||
proofBytes[0] ^= 0xFF
|
||||
tampered := base64.StdEncoding.EncodeToString(proofBytes)
|
||||
|
||||
err := handshake.CryptoInit2(f.tc, f.license, f.omega, tampered, f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error when proof verification fails")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package handshake
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/oasisprotocol/curve25519-voi/curve"
|
||||
"github.com/oasisprotocol/curve25519-voi/curve/scalar"
|
||||
)
|
||||
|
||||
var (
|
||||
errLicenseTooShort = errors.New("license too short")
|
||||
errUnsupportedLicenseVersion = errors.New("unsupported license version")
|
||||
errInvalidLicenseTimes = errors.New("license times are invalid")
|
||||
errIssuerStringNotTerminated = errors.New("non-null-terminated issuer string")
|
||||
errWrongKeyKindInLicense = errors.New("wrong key kind in license")
|
||||
errInvalidLicenseBlockType = errors.New("invalid license block type")
|
||||
)
|
||||
|
||||
var licenseRootKey = []byte{
|
||||
0xcd, 0x0d, 0xe2, 0xae, 0xd4, 0x63, 0x45, 0x50, 0x9a, 0x7e, 0x3c, 0xfd, 0x8f, 0x68, 0xb3, 0xdc, 0x75, 0x55, 0xb2,
|
||||
0x9d, 0xcc, 0xec, 0x73, 0xcd, 0x18, 0x75, 0x0f, 0x99, 0x38, 0x12, 0x40, 0x8a,
|
||||
}
|
||||
|
||||
type licenseBlockType byte
|
||||
|
||||
const (
|
||||
licenseBlockIntermediate licenseBlockType = 0
|
||||
licenseBlockServer licenseBlockType = 2
|
||||
licenseBlockTs5Server licenseBlockType = 8
|
||||
licenseBlockEphemeral licenseBlockType = 32
|
||||
)
|
||||
|
||||
type licenseBlock struct {
|
||||
key []byte
|
||||
hash []byte
|
||||
properties [][]byte // TS5/TS6 server license properties
|
||||
issuer string
|
||||
notValidBefore time.Time
|
||||
notValidAfter time.Time
|
||||
blockType licenseBlockType
|
||||
serverType byte
|
||||
}
|
||||
|
||||
type LicenseChain struct {
|
||||
Blocks []licenseBlock
|
||||
}
|
||||
|
||||
type blockPayload struct {
|
||||
read int
|
||||
issuer string
|
||||
serverType byte
|
||||
properties [][]byte
|
||||
}
|
||||
|
||||
func ParseLicenses(data []byte) (*LicenseChain, error) {
|
||||
if len(data) < 1 {
|
||||
return nil, errLicenseTooShort
|
||||
}
|
||||
if data[0] != 1 {
|
||||
return nil, errUnsupportedLicenseVersion
|
||||
}
|
||||
|
||||
data = data[1:]
|
||||
res := &LicenseChain{}
|
||||
for len(data) > 0 {
|
||||
block, read, err := parseLicenseBlock(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Blocks = append(res.Blocks, block)
|
||||
data = data[read:]
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (lc *LicenseChain) DeriveKey() ([]byte, error) {
|
||||
round := make([]byte, len(licenseRootKey))
|
||||
copy(round, licenseRootKey)
|
||||
for _, block := range lc.Blocks {
|
||||
next, err := block.deriveKey(round)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
round = next
|
||||
}
|
||||
|
||||
return round, nil
|
||||
}
|
||||
|
||||
func parseLicenseBlock(data []byte) (licenseBlock, int, error) {
|
||||
const minBlockLen = 42
|
||||
if len(data) < minBlockLen {
|
||||
return licenseBlock{}, 0, errLicenseTooShort
|
||||
}
|
||||
if data[0] != 0 {
|
||||
return licenseBlock{}, 0, fmt.Errorf("%w: %d", errWrongKeyKindInLicense, data[0])
|
||||
}
|
||||
|
||||
blockType := licenseBlockType(data[33])
|
||||
payload, err := parseBlockPayload(blockType, data, minBlockLen)
|
||||
if err != nil {
|
||||
return licenseBlock{}, 0, err
|
||||
}
|
||||
|
||||
notValidBefore := unixTimeStart.Add(time.Duration(binary.BigEndian.Uint32(data[34:38])+0x50e22700) * time.Second)
|
||||
notValidAfter := unixTimeStart.Add(time.Duration(binary.BigEndian.Uint32(data[38:42])+0x50e22700) * time.Second)
|
||||
if notValidAfter.Before(notValidBefore) {
|
||||
return licenseBlock{}, 0, errInvalidLicenseTimes
|
||||
}
|
||||
|
||||
key := make([]byte, 32)
|
||||
copy(key, data[1:33])
|
||||
allLen := minBlockLen + payload.read
|
||||
hash := crypto.Hash512(data[1:allLen])
|
||||
block := licenseBlock{
|
||||
blockType: blockType,
|
||||
issuer: payload.issuer,
|
||||
notValidBefore: notValidBefore,
|
||||
notValidAfter: notValidAfter,
|
||||
key: key,
|
||||
hash: hash[:32],
|
||||
serverType: payload.serverType,
|
||||
properties: payload.properties,
|
||||
}
|
||||
|
||||
return block, allLen, nil
|
||||
}
|
||||
|
||||
func parseBlockPayload(blockType licenseBlockType, data []byte, minBlockLen int) (blockPayload, error) {
|
||||
switch blockType {
|
||||
case licenseBlockIntermediate:
|
||||
return parseIntermediatePayload(data)
|
||||
case licenseBlockServer:
|
||||
return parseServerPayload(data)
|
||||
case licenseBlockTs5Server:
|
||||
return parseTs5ServerPayload(data, minBlockLen)
|
||||
case licenseBlockEphemeral:
|
||||
return blockPayload{}, nil
|
||||
default:
|
||||
return blockPayload{}, fmt.Errorf("%w: %d", errInvalidLicenseBlockType, blockType)
|
||||
}
|
||||
}
|
||||
|
||||
func parseIntermediatePayload(data []byte) (blockPayload, error) {
|
||||
issuer, read, err := readNullString(data[46:])
|
||||
if err != nil {
|
||||
return blockPayload{}, err
|
||||
}
|
||||
|
||||
return blockPayload{issuer: issuer, read: 5 + read}, nil
|
||||
}
|
||||
|
||||
func parseServerPayload(data []byte) (blockPayload, error) {
|
||||
issuer, read, err := readNullString(data[47:])
|
||||
if err != nil {
|
||||
return blockPayload{}, err
|
||||
}
|
||||
|
||||
return blockPayload{
|
||||
issuer: issuer,
|
||||
read: 6 + read,
|
||||
serverType: data[42],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseTs5ServerPayload(data []byte, minBlockLen int) (blockPayload, error) {
|
||||
propertyCount := int(data[43])
|
||||
pos := 44
|
||||
properties := make([][]byte, 0, propertyCount)
|
||||
for range propertyCount {
|
||||
if pos >= len(data) {
|
||||
return blockPayload{}, errLicenseTooShort
|
||||
}
|
||||
propLen := int(data[pos])
|
||||
pos++
|
||||
if pos+propLen > len(data) {
|
||||
return blockPayload{}, errLicenseTooShort
|
||||
}
|
||||
prop := make([]byte, propLen)
|
||||
copy(prop, data[pos:pos+propLen])
|
||||
properties = append(properties, prop)
|
||||
pos += propLen
|
||||
}
|
||||
|
||||
return blockPayload{
|
||||
read: pos - minBlockLen,
|
||||
serverType: data[42],
|
||||
properties: properties,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (lb *licenseBlock) deriveKey(parent []byte) ([]byte, error) {
|
||||
scalarBytes := make([]byte, 32)
|
||||
copy(scalarBytes, lb.hash)
|
||||
crypto.ClampScalar(scalarBytes)
|
||||
sc, err := scalar.NewFromBits(scalarBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pub := curve.NewEdwardsPoint()
|
||||
err = pub.UnmarshalBinary(lb.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub.Neg(pub)
|
||||
|
||||
par := curve.NewEdwardsPoint()
|
||||
err = par.UnmarshalBinary(parent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
par.Neg(par)
|
||||
|
||||
res := curve.NewEdwardsPoint().Mul(pub, sc)
|
||||
res.Add(res, par)
|
||||
|
||||
final, err := res.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
final[31] ^= 0x80
|
||||
|
||||
return final, nil
|
||||
}
|
||||
|
||||
func readNullString(data []byte) (string, int, error) {
|
||||
for i, b := range data {
|
||||
if b == 0 {
|
||||
return string(data[:i]), i, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", 0, errIssuerStringNotTerminated
|
||||
}
|
||||
|
||||
var unixTimeStart = time.Unix(0, 0)
|
||||
@@ -0,0 +1,222 @@
|
||||
package handshake_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/handshake"
|
||||
)
|
||||
|
||||
// Real-world TeamSpeak anonymous license captured from a live server handshake.
|
||||
const testLicenseBase64 = "AQBgjAAqtcBUrw5futTtkl3+EM3OW4Lal6OTPlwuv4xV/gIRFlEAG0Nl" +
|
||||
"AAcAAAAgQW5vbnltb3VzAACWSZf+Mjl5RT5mu4rvf8nhAZp9TjXO10XfGHQ9HQPtHiAYiqjtGItRrQ=="
|
||||
|
||||
func decodeTestLicense(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
data, err := base64.StdEncoding.DecodeString(testLicenseBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("base64 decode failed: %v", err)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func TestParseLicensesValid(t *testing.T) {
|
||||
data := decodeTestLicense(t)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLicenses failed: %v", err)
|
||||
}
|
||||
if len(chain.Blocks) != 2 {
|
||||
t.Errorf("expected 2 blocks, got %d", len(chain.Blocks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLicensesEmptyInput(t *testing.T) {
|
||||
_, err := handshake.ParseLicenses([]byte{})
|
||||
if err == nil {
|
||||
t.Error("expected error for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLicensesWrongVersion(t *testing.T) {
|
||||
// Version byte at index 0 must be 1
|
||||
_, err := handshake.ParseLicenses([]byte{0x02, 0x00})
|
||||
if err == nil {
|
||||
t.Error("expected error for unsupported version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLicensesTooShortBlock(t *testing.T) {
|
||||
// Version OK but block data too short (< 42 bytes)
|
||||
data := make([]byte, 10)
|
||||
data[0] = 0x01 // valid version
|
||||
// remaining 9 bytes are not enough for a license block (needs 42)
|
||||
_, err := handshake.ParseLicenses(data)
|
||||
if err == nil {
|
||||
t.Error("expected error for truncated block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyLength(t *testing.T) {
|
||||
data := decodeTestLicense(t)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKey failed: %v", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
t.Errorf("expected 32-byte key, got %d bytes", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyDeterministic(t *testing.T) {
|
||||
data := decodeTestLicense(t)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key1, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key2, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hex.EncodeToString(key1) != hex.EncodeToString(key2) {
|
||||
t.Error("DeriveKey is not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyNonZero(t *testing.T) {
|
||||
data := decodeTestLicense(t)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allZero := true
|
||||
for _, b := range key {
|
||||
if b != 0 {
|
||||
allZero = false
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if allZero {
|
||||
t.Error("derived key should not be all zeros")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLicensesExpectedKeyKnownValue(t *testing.T) {
|
||||
// Known expected key derived from this specific anonymous license.
|
||||
const expectedKeyHex = "82a168e11f9f3e3496fbf8479cd3e17d9b0945e224a71fb371af619a256b8446"
|
||||
data := decodeTestLicense(t)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := hex.EncodeToString(key); got != expectedKeyHex {
|
||||
t.Errorf("DeriveKey = %s, want %s", got, expectedKeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
// TS5/TS6 server license block (type 8) tests
|
||||
|
||||
// buildTs5LicenseBlob constructs a synthetic version-1 license containing a
|
||||
// single Ts5Server block (type 8) with the given properties.
|
||||
func buildTs5LicenseBlob(props [][]byte) []byte {
|
||||
// Block layout:
|
||||
// [0] key kind = 0
|
||||
// [1:33] 32-byte Ed25519 public key (identity point)
|
||||
// [33] block type = 8
|
||||
// [34:38] not valid before (BE uint32)
|
||||
// [38:42] not valid after (BE uint32)
|
||||
// [42] server license type
|
||||
// [43] property count
|
||||
// [44+] length-prefixed properties
|
||||
const headerSize = 44
|
||||
totalPropsSize := 0
|
||||
for _, p := range props {
|
||||
totalPropsSize += 1 + len(p)
|
||||
}
|
||||
block := make([]byte, headerSize, headerSize+totalPropsSize)
|
||||
block[0] = 0x00
|
||||
block[1] = 0x01 // Ed25519 identity point (0,1)
|
||||
block[33] = 0x08
|
||||
binary.BigEndian.PutUint32(block[34:38], 0x00000000)
|
||||
binary.BigEndian.PutUint32(block[38:42], 0x7FFFFFFF)
|
||||
block[42] = 7
|
||||
block[43] = byte(len(props))
|
||||
for _, p := range props {
|
||||
block = append(block, byte(len(p)))
|
||||
block = append(block, p...)
|
||||
}
|
||||
|
||||
return append([]byte{0x01}, block...) // version prefix
|
||||
}
|
||||
|
||||
func TestParseTs5ServerBlock(t *testing.T) {
|
||||
data := buildTs5LicenseBlob([][]byte{
|
||||
[]byte("issuer.example.com"),
|
||||
{0xDE, 0xAD},
|
||||
})
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLicenses failed: %v", err)
|
||||
}
|
||||
if len(chain.Blocks) != 1 {
|
||||
t.Errorf("expected 1 block, got %d", len(chain.Blocks))
|
||||
}
|
||||
key, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKey failed: %v", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
t.Errorf("expected 32-byte key, got %d", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTs5ServerBlockZeroProperties(t *testing.T) {
|
||||
data := buildTs5LicenseBlob(nil)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLicenses failed: %v", err)
|
||||
}
|
||||
if len(chain.Blocks) != 1 {
|
||||
t.Errorf("expected 1 block, got %d", len(chain.Blocks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTs5ServerBlockTruncatedPropertyData(t *testing.T) {
|
||||
data := buildTs5LicenseBlob([][]byte{{0x01, 0x02, 0x03}})
|
||||
// Chop off last byte so the property data is incomplete.
|
||||
data = data[:len(data)-1]
|
||||
_, err := handshake.ParseLicenses(data)
|
||||
if err == nil {
|
||||
t.Error("expected error for truncated property data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTs5ServerBlockTruncatedPropertyLength(t *testing.T) {
|
||||
// Claim 2 properties but only provide 1.
|
||||
data := buildTs5LicenseBlob([][]byte{{0xAA}})
|
||||
data[1+43] = 2 // override property count to 2
|
||||
_, err := handshake.ParseLicenses(data)
|
||||
if err == nil {
|
||||
t.Error("expected error when property count exceeds available data")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user