From e98625ef587592ed7c705d61c406dc74b11b26e9 Mon Sep 17 00:00:00 2001 From: robert Date: Sun, 4 Jan 2026 20:18:59 +0000 Subject: [PATCH] Initial commit: crypto.go and protocol.go implementation + unit tests --- crypto.go | 209 ++++++++++++++++++++++++++++++++++++++ crypto_test.go | 108 ++++++++++++++++++++ go.mod | 23 +++++ go.sum | 69 +++++++++++++ ownwire_sdk_suite_test.go | 14 +++ protocol.go | 47 +++++++++ protocol_test.go | 36 +++++++ 7 files changed, 506 insertions(+) create mode 100644 crypto.go create mode 100644 crypto_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 ownwire_sdk_suite_test.go create mode 100644 protocol.go create mode 100644 protocol_test.go diff --git a/crypto.go b/crypto.go new file mode 100644 index 0000000..e6946e8 --- /dev/null +++ b/crypto.go @@ -0,0 +1,209 @@ +package ownwire_sdk + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/hkdf" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" +) + +const ( + hkdf_info_prefix = "ownwire/v1:" + nonce_label = "ownwire/v1:gcm-nonce" +) + +type Keypair struct { + ClientPriv *ecdh.PrivateKey + ClientPub []byte // 65 bytes, uncompressed +} + +func GenClientKey() (Keypair, error) { + curve := ecdh.P256() + + client_priv, err := curve.GenerateKey(rand.Reader) + if err != nil { + return Keypair{}, err + } + + client_pub := client_priv.PublicKey().Bytes() + if len(client_pub) != 65 { + return Keypair{}, fmt.Errorf("unexpected P-256 pubkey length: %d", len(client_pub)) + } + + return Keypair{ + ClientPriv: client_priv, + ClientPub: client_pub, + }, nil +} + +// DeriveSharedKey does: +// ECDH -> 32 bytes shared secret +// HKDF-SHA256(salt, info="ownwire/v1:") -> 32 bytes +func DeriveSharedKey(session_id string, client_priv *ecdh.PrivateKey, server_pub_raw []byte, salt []byte) ([32]byte, error) { + var out [32]byte + + if client_priv == nil { + return out, fmt.Errorf("client_priv is nil") + } + + curve := ecdh.P256() + server_pub, err := curve.NewPublicKey(server_pub_raw) + if err != nil { + return out, fmt.Errorf("invalid server pubkey: %w", err) + } + + shared_secret, err := client_priv.ECDH(server_pub) + if err != nil { + return out, fmt.Errorf("ecdh failed: %w", err) + } + + info_str := hkdf_info_prefix + session_id + prk, err := hkdf.Extract(sha256.New, shared_secret, salt) + if err != nil { + return out, fmt.Errorf("hkdf extract failed: %w", err) + } + okm, err := hkdf.Expand(sha256.New, prk, info_str, 32) + if err != nil { + return out, fmt.Errorf("hkdf expand failed: %w", err) + } + + copy(out[:], okm) + + zero_bytes(shared_secret) + return out, nil +} + +// DeriveNonce computes: +// HMAC-SHA256(key=shared_key, data=label + uuid_bytes + salt16 + seq_be8 + flag) +// IV = first 12 bytes +func DeriveNonce(shared_key [32]byte, session_uuid_bytes [16]byte, salt16 [16]byte, seq_num uint64, is_response bool) [12]byte { + var iv [12]byte + + seq_be8 := [8]byte{} + for i := 7; i >= 0; i-- { + seq_be8[i] = byte(seq_num & 0xff) + seq_num >>= 8 + } + + flag := byte(0) + if is_response { + flag = 1 + } + + mac := hmac.New(sha256.New, shared_key[:]) + mac.Write([]byte(nonce_label)) + mac.Write(session_uuid_bytes[:]) + mac.Write(salt16[:]) + mac.Write(seq_be8[:]) + mac.Write([]byte{flag}) + + sum := mac.Sum(nil) // 32 bytes + copy(iv[:], sum[:12]) + return iv +} + +type EncryptedPayload struct { + ContentB64 string + SaltHex string +} + +func EncryptAESGCM(shared_key [32]byte, session_uuid_bytes [16]byte, plain_text []byte, seq_num uint64, is_response bool) (EncryptedPayload, error) { + var salt16 [16]byte + if _, err := rand.Read(salt16[:]); err != nil { + return EncryptedPayload{}, err + } + + iv := DeriveNonce(shared_key, session_uuid_bytes, salt16, seq_num, is_response) + + block, err := aes.NewCipher(shared_key[:]) + if err != nil { + return EncryptedPayload{}, err + } + + aead, err := cipher.NewGCM(block) + if err != nil { + return EncryptedPayload{}, err + } + + ct := aead.Seal(nil, iv[:], plain_text, nil) + + return EncryptedPayload{ + ContentB64: base64.StdEncoding.EncodeToString(ct), + SaltHex: hex.EncodeToString(salt16[:]), + }, nil +} + +func DecryptAESGCM(shared_key [32]byte, session_uuid_bytes [16]byte, content_b64 string, salt_hex string, seq_num uint64, is_response bool) ([]byte, error) { + ct, err := base64.StdEncoding.DecodeString(content_b64) + if err != nil { + return nil, fmt.Errorf("invalid content base64: %w", err) + } + + salt_raw, err := hex.DecodeString(salt_hex) + if err != nil { + return nil, fmt.Errorf("invalid salt hex: %w", err) + } + if len(salt_raw) != 16 { + return nil, fmt.Errorf("invalid salt length: %d", len(salt_raw)) + } + + var salt16 [16]byte + copy(salt16[:], salt_raw) + + iv := DeriveNonce(shared_key, session_uuid_bytes, salt16, seq_num, is_response) + + block, err := aes.NewCipher(shared_key[:]) + if err != nil { + return nil, err + } + + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + pt, err := aead.Open(nil, iv[:], ct, nil) + if err != nil { + return nil, err + } + + return pt, nil +} + +// ParseUUIDBytes parses "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" into 16 bytes. +func ParseUUIDBytes(uuid_str string) ([16]byte, error) { + var out [16]byte + + clean := make([]byte, 0, 32) + for i := 0; i < len(uuid_str); i++ { + b := uuid_str[i] + if b == '-' { + continue + } + clean = append(clean, b) + } + + if len(clean) != 32 { + return out, fmt.Errorf("invalid uuid: %q", uuid_str) + } + + decoded, err := hex.DecodeString(string(clean)) + if err != nil { + return out, fmt.Errorf("invalid uuid hex: %w", err) + } + copy(out[:], decoded) + return out, nil +} + +func zero_bytes(b []byte) { + for i := range b { + b[i] = 0 + } +} + diff --git a/crypto_test.go b/crypto_test.go new file mode 100644 index 0000000..34f1868 --- /dev/null +++ b/crypto_test.go @@ -0,0 +1,108 @@ +package ownwire_sdk_test + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + sdk "ownwire.net/ownwire-sdk" +) + +func derive_nonce_reference(shared_key [32]byte, session_uuid_bytes [16]byte, salt16 [16]byte, seq_num uint64, is_response bool) [12]byte { + seq_be8 := [8]byte{} + for i := 7; i >= 0; i-- { + seq_be8[i] = byte(seq_num & 0xff) + seq_num >>= 8 + } + + flag := byte(0) + if is_response { + flag = 1 + } + + mac := hmac.New(sha256.New, shared_key[:]) + mac.Write([]byte("ownwire/v1:gcm-nonce")) + mac.Write(session_uuid_bytes[:]) + mac.Write(salt16[:]) + mac.Write(seq_be8[:]) + mac.Write([]byte{flag}) + + sum := mac.Sum(nil) + + var iv [12]byte + copy(iv[:], sum[:12]) + return iv +} + +var _ = Describe("Crypto", func() { + It("parses UUID bytes", func() { + uuid_str := "cb653f53-6f7d-4aeb-ba0d-d2b17c290d8a" + uuid_bytes, err := sdk.ParseUUIDBytes(uuid_str) + Expect(err).To(BeNil()) + Expect(hex.EncodeToString(uuid_bytes[:])).To(Equal("cb653f536f7d4aebba0dd2b17c290d8a")) + }) + + It("derives nonce exactly as reference implementation", func() { + var shared_key [32]byte + for i := 0; i < 32; i++ { + shared_key[i] = byte(i) + } + + uuid_bytes, err := sdk.ParseUUIDBytes("cb653f53-6f7d-4aeb-ba0d-d2b17c290d8a") + Expect(err).To(BeNil()) + + var salt16 [16]byte + for i := 0; i < 16; i++ { + salt16[i] = byte(0xa0 + i) + } + + seq_num := uint64(0x0102030405060708) + + got := sdk.DeriveNonce(shared_key, uuid_bytes, salt16, seq_num, false) + want := derive_nonce_reference(shared_key, uuid_bytes, salt16, seq_num, false) + + Expect(got).To(Equal(want)) + }) + + It("encrypts and decrypts roundtrip", func() { + var shared_key [32]byte + for i := 0; i < 32; i++ { + shared_key[i] = byte(0x55 ^ i) + } + + uuid_bytes, err := sdk.ParseUUIDBytes("cb653f53-6f7d-4aeb-ba0d-d2b17c290d8a") + Expect(err).To(BeNil()) + + plain_text := []byte("hello ownwire") + seq_num := uint64(123) + + enc, err := sdk.EncryptAESGCM(shared_key, uuid_bytes, plain_text, seq_num, false) + Expect(err).To(BeNil()) + Expect(enc.ContentB64).ToNot(BeEmpty()) + Expect(enc.SaltHex).To(HaveLen(32)) // 16 bytes hex + + out, err := sdk.DecryptAESGCM(shared_key, uuid_bytes, enc.ContentB64, enc.SaltHex, seq_num, false) + Expect(err).To(BeNil()) + Expect(string(out)).To(Equal(string(plain_text))) + }) + + It("fails decrypt if seq changes", func() { + var shared_key [32]byte + for i := 0; i < 32; i++ { + shared_key[i] = byte(0x11 + i) + } + + uuid_bytes, err := sdk.ParseUUIDBytes("cb653f53-6f7d-4aeb-ba0d-d2b17c290d8a") + Expect(err).To(BeNil()) + + enc, err := sdk.EncryptAESGCM(shared_key, uuid_bytes, []byte("hello"), 1, false) + Expect(err).To(BeNil()) + + _, err = sdk.DecryptAESGCM(shared_key, uuid_bytes, enc.ContentB64, enc.SaltHex, 2, false) + Expect(err).ToNot(BeNil()) + }) +}) + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..057812a --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module ownwire.net/ownwire-sdk + +go 1.25.4 + +require ( + github.com/onsi/ginkgo/v2 v2.27.3 + github.com/onsi/gomega v1.38.3 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.36.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7d22274 --- /dev/null +++ b/go.sum @@ -0,0 +1,69 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8= +github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ownwire_sdk_suite_test.go b/ownwire_sdk_suite_test.go new file mode 100644 index 0000000..742b60d --- /dev/null +++ b/ownwire_sdk_suite_test.go @@ -0,0 +1,14 @@ +package ownwire_sdk_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestOwnwireSdk(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Ownwire SDK Suite") +} + diff --git a/protocol.go b/protocol.go new file mode 100644 index 0000000..c9f6aae --- /dev/null +++ b/protocol.go @@ -0,0 +1,47 @@ +package ownwire_sdk + +import ( + "fmt" + "strconv" + "strings" +) + +type SessionInit struct { + SessionId string + ServerPubKeyB64 string + SaltB64 string + SeqOut uint64 + SeqInMax uint64 +} + +// ParseSessionInit parses: +// "/session:::::" +func ParseSessionInit(line string) (SessionInit, error) { + if !strings.HasPrefix(line, "/session:") { + return SessionInit{}, fmt.Errorf("invalid session line prefix: %q", line) + } + + parts := strings.Split(line, ":") + if len(parts) < 6 || parts[0] != "/session" { + return SessionInit{}, fmt.Errorf("invalid session line: %q", line) + } + + seq_out, err := strconv.ParseUint(parts[4], 10, 64) + if err != nil { + return SessionInit{}, fmt.Errorf("invalid seq_out: %w", err) + } + + seq_in_max, err := strconv.ParseUint(parts[5], 10, 64) + if err != nil { + return SessionInit{}, fmt.Errorf("invalid seq_in_max: %w", err) + } + + out := SessionInit{ + SessionId: parts[1], + ServerPubKeyB64: parts[2], + SaltB64: parts[3], + SeqOut: seq_out, + SeqInMax: seq_in_max, + } + return out, nil +} diff --git a/protocol_test.go b/protocol_test.go new file mode 100644 index 0000000..6620b3c --- /dev/null +++ b/protocol_test.go @@ -0,0 +1,36 @@ +package ownwire_sdk_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + sdk "ownwire.net/ownwire-sdk" +) + +var _ = Describe("ParseSessionInit", func() { + It("parses a valid /session line", func() { + line := "/session:cb653f53-6f7d-4aeb-ba0d-d2b17c290d8a:SERVERPUBB64:SALTB64:12:34" + parsed, err := sdk.ParseSessionInit(line) + Expect(err).To(BeNil()) + Expect(parsed.SessionId).To(Equal("cb653f53-6f7d-4aeb-ba0d-d2b17c290d8a")) + Expect(parsed.ServerPubKeyB64).To(Equal("SERVERPUBB64")) + Expect(parsed.SaltB64).To(Equal("SALTB64")) + Expect(parsed.SeqOut).To(Equal(uint64(12))) + Expect(parsed.SeqInMax).To(Equal(uint64(34))) + }) + + It("rejects non-session lines", func() { + _, err := sdk.ParseSessionInit("/wat:1:2:3:4:5") + Expect(err).ToNot(BeNil()) + }) + + It("rejects missing fields", func() { + _, err := sdk.ParseSessionInit("/session:1:2:3") + Expect(err).ToNot(BeNil()) + }) + + It("rejects bad sequence numbers", func() { + _, err := sdk.ParseSessionInit("/session:1:2:3:nope:5") + Expect(err).ToNot(BeNil()) + }) +})