LAN connection string (#2653)

* Version bump

* Implemented lan connection string functionality

Also added more robust testing

* Added ConnectionParams struct and related funcs

* Add server mode to ConnectionParams
This commit is contained in:
Samuel Hawksby-Robinson 2022-06-15 16:36:17 +01:00 committed by GitHub
parent 7f149f93c1
commit 05b8ddf57a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 444 additions and 61 deletions

View File

@ -1 +1 @@
0.101.2 0.101.3

2
go.mod
View File

@ -15,6 +15,7 @@ replace github.com/raulk/go-watchdog v1.2.0 => github.com/status-im/go-watchdog
require ( require (
github.com/anacrolix/torrent v1.41.0 github.com/anacrolix/torrent v1.41.0
github.com/beevik/ntp v0.2.0 github.com/beevik/ntp v0.2.0
github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce
github.com/cenkalti/backoff/v3 v3.2.2 github.com/cenkalti/backoff/v3 v3.2.2
github.com/davecgh/go-spew v1.1.1 github.com/davecgh/go-spew v1.1.1
github.com/deckarep/golang-set v1.8.0 github.com/deckarep/golang-set v1.8.0
@ -104,7 +105,6 @@ require (
github.com/bits-and-blooms/bitset v1.2.0 // indirect github.com/bits-and-blooms/bitset v1.2.0 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/btcsuite/btcd v0.22.0-beta // indirect github.com/btcsuite/btcd v0.22.0-beta // indirect
github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/cheekybits/genny v1.0.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect
github.com/containerd/cgroups v0.0.0-20201119153540-4cbc285b3327 // indirect github.com/containerd/cgroups v0.0.0-20201119153540-4cbc285b3327 // indirect

View File

@ -9,6 +9,7 @@ import (
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/pem" "encoding/pem"
"fmt"
"math/big" "math/big"
"net" "net"
"time" "time"
@ -110,13 +111,8 @@ func PublicTLSCert() (string, error) {
return globalPem, nil return globalPem, nil
} }
func GenerateCertFromKey(pk *ecdsa.PrivateKey, ttl time.Duration, hostname string) (tls.Certificate, []byte, error) { func GenerateCertFromKey(pk *ecdsa.PrivateKey, from time.Time, hostname string) (tls.Certificate, []byte, error) {
// TODO fix, this isn't deterministic, cert := GenerateX509Cert(makeSerialNumberFromKey(pk), from, from.Add(time.Hour), hostname)
notBefore := time.Now()
notAfter := notBefore.Add(ttl)
cert := GenerateX509Cert(makeSerialNumberFromKey(pk), notBefore, notAfter, hostname)
certPem, keyPem, err := GenerateX509PEMs(cert, pk) certPem, keyPem, err := GenerateX509PEMs(cert, pk)
if err != nil { if err != nil {
return tls.Certificate{}, nil, err return tls.Certificate{}, nil, err
@ -127,6 +123,16 @@ func GenerateCertFromKey(pk *ecdsa.PrivateKey, ttl time.Duration, hostname strin
return tls.Certificate{}, nil, err return tls.Certificate{}, nil, err
} }
block, _ := pem.Decode(certPem)
if block == nil {
return tls.Certificate{}, nil, fmt.Errorf("failed to decode certPem")
}
leaf, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return tls.Certificate{}, nil, err
}
tlsCert.Leaf = leaf
return tlsCert, certPem, nil return tlsCert, certPem, nil
} }

View File

@ -1,9 +1,6 @@
package server package server
import ( import (
"crypto/ecdsa"
"crypto/elliptic"
"math/big"
"testing" "testing"
"time" "time"
@ -15,54 +12,19 @@ func TestCerts(t *testing.T) {
suite.Run(t, new(CertsSuite)) suite.Run(t, new(CertsSuite))
} }
const (
X = "7744735542292224619198421067303535767629647588258222392379329927711683109548"
Y = "6855516769916529066379811647277920115118980625614889267697023742462401590771"
D = "38564357061962143106230288374146033267100509055924181407058066820384455255240"
DB58 = "6jpbvo2ucrtrnpXXF4DQYuysh697isH9ppd2aT8uSRDh"
SN = "91849736469742262272885892667727604096707836853856473239722372976236128900962"
)
type CertsSuite struct { type CertsSuite struct {
suite.Suite suite.Suite
TestKeyComponents
X *big.Int TestCertComponents
Y *big.Int
D *big.Int
DBytes []byte
SN *big.Int
} }
func (s *CertsSuite) SetupSuite() { func (s *CertsSuite) SetupSuite() {
var ok bool s.SetupKeyComponents(s.T())
s.SetupCertComponents(s.T())
s.X, ok = new(big.Int).SetString(X, 10)
s.Require().True(ok)
s.Y, ok = new(big.Int).SetString(Y, 10)
s.Require().True(ok)
s.D, ok = new(big.Int).SetString(D, 10)
s.Require().True(ok)
s.DBytes = base58.Decode(DB58)
s.Require().Exactly(s.D.Bytes(), s.DBytes)
s.SN, ok = new(big.Int).SetString(SN, 10)
s.Require().True(ok)
} }
func (s *CertsSuite) Test_makeSerialNumberFromKey() { func (s *CertsSuite) Test_makeSerialNumberFromKey() {
pk := &ecdsa.PrivateKey{ s.Require().Zero(makeSerialNumberFromKey(s.PK).Cmp(s.SN))
PublicKey: ecdsa.PublicKey{
Curve: elliptic.P256(),
X: s.X,
Y: s.Y,
},
D: s.D,
}
s.Require().Zero(makeSerialNumberFromKey(pk).Cmp(s.SN))
} }
func (s *CertsSuite) TestToECDSA() { func (s *CertsSuite) TestToECDSA() {

72
server/components_test.go Normal file
View File

@ -0,0 +1,72 @@
package server
import (
"crypto/ecdsa"
"crypto/elliptic"
"encoding/asn1"
"math/big"
"testing"
"time"
"github.com/btcsuite/btcutil/base58"
"github.com/stretchr/testify/require"
)
const (
X = "7744735542292224619198421067303535767629647588258222392379329927711683109548"
Y = "6855516769916529066379811647277920115118980625614889267697023742462401590771"
D = "38564357061962143106230288374146033267100509055924181407058066820384455255240"
DB58 = "6jpbvo2ucrtrnpXXF4DQYuysh697isH9ppd2aT8uSRDh"
SN = "91849736469742262272885892667727604096707836853856473239722372976236128900962"
CertTime = "eQUriVtGtkWhPJFeLZjF"
)
type TestKeyComponents struct {
X *big.Int
Y *big.Int
D *big.Int
DBytes []byte
PK *ecdsa.PrivateKey
}
func (tk *TestKeyComponents) SetupKeyComponents(t *testing.T) {
var ok bool
tk.X, ok = new(big.Int).SetString(X, 10)
require.True(t, ok)
tk.Y, ok = new(big.Int).SetString(Y, 10)
require.True(t, ok)
tk.D, ok = new(big.Int).SetString(D, 10)
require.True(t, ok)
tk.DBytes = base58.Decode(DB58)
require.Exactly(t, tk.D.Bytes(), tk.DBytes)
tk.PK = &ecdsa.PrivateKey{
PublicKey: ecdsa.PublicKey{
Curve: elliptic.P256(),
X: tk.X,
Y: tk.Y,
},
D: tk.D,
}
}
type TestCertComponents struct {
NotBefore, NotAfter time.Time
SN *big.Int
}
func (tcc *TestCertComponents) SetupCertComponents(t *testing.T) {
var ok bool
tcc.SN, ok = new(big.Int).SetString(SN, 10)
require.True(t, ok)
_, err := asn1.Unmarshal(base58.Decode(CertTime), &tcc.NotBefore)
require.NoError(t, err)
tcc.NotAfter = tcc.NotBefore.Add(time.Hour)
}

196
server/connection.go Normal file
View File

@ -0,0 +1,196 @@
package server
import (
"crypto/ecdsa"
"encoding/asn1"
"fmt"
"math/big"
"net"
"net/url"
"strings"
"time"
"github.com/btcsuite/btcutil/base58"
)
type ConnectionParamVersion int
type Mode int
const (
Version1 ConnectionParamVersion = iota + 1
)
const (
Receiving Mode = iota + 1
Sending
)
type ConnectionParams struct {
version ConnectionParamVersion
netIP net.IP
port int
privateKey *ecdsa.PrivateKey
notBefore time.Time
serverMode Mode
}
func NewConnectionParams(netIP net.IP, port int, privateKey *ecdsa.PrivateKey, notBefore time.Time, mode Mode) *ConnectionParams {
cp := new(ConnectionParams)
cp.version = Version1
cp.netIP = netIP
cp.port = port
cp.privateKey = privateKey
cp.notBefore = notBefore
cp.serverMode = mode
return cp
}
// ToString generates a string required for generating a secure connection to another Status device.
//
// The returned string will look like below:
// - "2:4FHRnp:H6G:6jpbvo2ucrtrnpXXF4DQYuysh697isH9ppd2aT8uSRDh:eQUriVtGtkWhPJFeLZjF:2"
//
// Format bytes encoded into a base58 string, delimited by ":"
// - version
// - net.IP
// - port
// - ecdsa.PrivateKey D field
// - asn1.Marshal time.Time
// - server mode
func (cp *ConnectionParams) ToString() (string, error) {
v := base58.Encode(new(big.Int).SetInt64(int64(cp.version)).Bytes())
ip := base58.Encode(cp.netIP)
p := base58.Encode(new(big.Int).SetInt64(int64(cp.port)).Bytes())
k := base58.Encode(cp.privateKey.D.Bytes())
tb, err := asn1.Marshal(cp.notBefore.UTC())
if err != nil {
return "", err
}
t := base58.Encode(tb)
m := base58.Encode(new(big.Int).SetInt64(int64(cp.serverMode)).Bytes())
return fmt.Sprintf("%s:%s:%s:%s:%s:%s", v, ip, p, k, t, m), nil
}
// FromString parses a connection params string required for to securely connect to another Status device.
// This function parses a connection string generated by ToString
func (cp *ConnectionParams) FromString(s string) error {
requiredParams := 6
sData := strings.Split(s, ":")
if len(sData) != requiredParams {
return fmt.Errorf("expected data '%s' to have length of '%d', received '%d'", s, requiredParams, len(sData))
}
cp.version = ConnectionParamVersion(new(big.Int).SetBytes(base58.Decode(sData[0])).Int64())
cp.netIP = base58.Decode(sData[1])
cp.port = int(new(big.Int).SetBytes(base58.Decode(sData[2])).Int64())
cp.privateKey = ToECDSA(base58.Decode(sData[3]))
t := time.Time{}
_, err := asn1.Unmarshal(base58.Decode(sData[4]), &t)
if err != nil {
return err
}
cp.notBefore = t
cp.serverMode = Mode(new(big.Int).SetBytes(base58.Decode(sData[5])).Int64())
return cp.validate()
}
func (cp *ConnectionParams) validate() error {
err := cp.validateVersion()
if err != nil {
return err
}
err = cp.validateNetIP()
if err != nil {
return err
}
err = cp.validatePort()
if err != nil {
return err
}
err = cp.validatePrivateKey()
if err != nil {
return err
}
err = cp.validateNotBefore()
if err != nil {
return err
}
return cp.validateServerMode()
}
func (cp *ConnectionParams) validateVersion() error {
switch cp.version {
case Version1:
return nil
default:
return fmt.Errorf("unsupported version '%d'", cp.version)
}
}
func (cp *ConnectionParams) validateNetIP() error {
if ok := net.ParseIP(cp.netIP.String()); ok == nil {
return fmt.Errorf("invalid net ip '%s'", cp.netIP)
}
return nil
}
func (cp *ConnectionParams) validatePort() error {
if cp.port > 0 && cp.port < 0x10000 {
return nil
}
return fmt.Errorf("port '%d' outside of bounds of 1 - 65535", cp.port)
}
func (cp *ConnectionParams) validatePrivateKey() error {
switch {
case cp.privateKey.D == nil, cp.privateKey.D.Cmp(big.NewInt(0)) == 0:
return fmt.Errorf("private key D not set")
case cp.privateKey.PublicKey.X == nil, cp.privateKey.PublicKey.X.Cmp(big.NewInt(0)) == 0:
return fmt.Errorf("public key X not set")
case cp.privateKey.PublicKey.Y == nil, cp.privateKey.PublicKey.Y.Cmp(big.NewInt(0)) == 0:
return fmt.Errorf("public key Y not set")
default:
return nil
}
}
func (cp *ConnectionParams) validateNotBefore() error {
if cp.notBefore.IsZero() {
return fmt.Errorf("notBefore time is zero")
}
return nil
}
func (cp *ConnectionParams) validateServerMode() error {
switch cp.serverMode {
case Receiving, Sending:
return nil
default:
return fmt.Errorf("invalid server mode '%d'", cp.serverMode)
}
}
// Generate returns a *url.URL and encoded pem.Block generated from ConnectionParams set fields
func (cp *ConnectionParams) Generate() (*url.URL, []byte, error) {
u := &url.URL{
Scheme: "https",
Host: fmt.Sprintf("%s:%d", cp.netIP, cp.port),
}
_, pem, err := GenerateCertFromKey(cp.privateKey, cp.notBefore, cp.netIP.String())
if err != nil {
return nil, nil, err
}
return u, pem, nil
}

85
server/connection_test.go Normal file
View File

@ -0,0 +1,85 @@
package server
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"testing"
"github.com/stretchr/testify/suite"
)
var (
connectionString = "2:4FHRnp:Q4:6jpbvo2ucrtrnpXXF4DQYuysh697isH9ppd2aT8uSRDh:eQUriVtGtkWhPJFeLZjF:3"
)
func TestConnectionParamsSuite(t *testing.T) {
suite.Run(t, new(ConnectionParamsSuite))
}
type ConnectionParamsSuite struct {
suite.Suite
TestKeyComponents
TestCertComponents
server *PairingServer
}
func (s *ConnectionParamsSuite) SetupSuite() {
s.SetupKeyComponents(s.T())
s.SetupCertComponents(s.T())
cert, _, err := GenerateCertFromKey(s.PK, s.NotBefore, defaultIP.String())
s.Require().NoError(err)
bs := NewServer(&cert, defaultIP.String())
bs.port = 1337
s.server = &PairingServer{
Server: bs,
pk: s.PK,
mode: Sending,
}
}
func (s *ConnectionParamsSuite) TestConnectionParams_ToString() {
cp, err := s.server.MakeConnectionParams()
s.Require().NoError(err)
cps, err := cp.ToString()
s.Require().NoError(err)
s.Require().Equal(connectionString, cps)
}
func (s *ConnectionParamsSuite) TestConnectionParams_Generate() {
cp := new(ConnectionParams)
err := cp.FromString(connectionString)
s.Require().NoError(err)
s.Require().Exactly(Sending, cp.serverMode)
u, c, err := cp.Generate()
s.Require().NoError(err)
s.Require().Equal("https://127.0.0.1:1337", u.String())
s.Require().Equal(defaultIP.String(), u.Hostname())
s.Require().Equal("1337", u.Port())
// Parse cert PEM into x509 cert
block, _ := pem.Decode(c)
s.Require().NotNil(block)
cert, err := x509.ParseCertificate(block.Bytes)
s.Require().NoError(err)
// Compare cert values
cl := s.server.cert.Leaf
s.Require().NotEqual(cl.Signature, cert.Signature)
s.Require().Zero(cl.PublicKey.(*ecdsa.PublicKey).X.Cmp(cert.PublicKey.(*ecdsa.PublicKey).X))
s.Require().Zero(cl.PublicKey.(*ecdsa.PublicKey).Y.Cmp(cert.PublicKey.(*ecdsa.PublicKey).Y))
s.Require().Equal(cl.Version, cert.Version)
s.Require().Zero(cl.SerialNumber.Cmp(cert.SerialNumber))
s.Require().Exactly(cl.NotBefore, cert.NotBefore)
s.Require().Exactly(cl.NotAfter, cert.NotAfter)
s.Require().Exactly(cl.IPAddresses, cert.IPAddresses)
}

View File

@ -12,7 +12,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -31,25 +30,46 @@ func testHandler(t *testing.T) func(w http.ResponseWriter, r *http.Request) {
} }
func TestGetOutboundIPWithFullServerE2e(t *testing.T) { func TestGetOutboundIPWithFullServerE2e(t *testing.T) {
// Get 3 key components for tls.cert generation
// 1) Ephemeral private key
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err) require.NoError(t, err)
// 2) Device outbound IP address
ip, err := GetOutboundIP() ip, err := GetOutboundIP()
require.NoError(t, err) require.NoError(t, err)
cert, certPem, err := GenerateCertFromKey(pk, time.Hour, ip.String()) // 3) NotBefore time
certTime := time.Now()
// Generate tls.Certificate and Server
cert, _, err := GenerateCertFromKey(pk, certTime, ip.String())
require.NoError(t, err) require.NoError(t, err)
s := NewPairingServer(&Config{&cert, ip.String()}) s := NewPairingServer(&Config{pk, &cert, ip.String(), Sending})
s.SetHandlers(HandlerPatternMap{"/hello": testHandler(t)}) s.SetHandlers(HandlerPatternMap{"/hello": testHandler(t)})
err = s.Start() err = s.Start()
require.NoError(t, err) require.NoError(t, err)
// Give time for the sever to be ready, hacky I know // Give time for the sever to be ready, hacky I know, I'll iron this out
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
spew.Dump(s.MakeBaseURL().String())
// Server generates a QR code connection string
cp, err := s.MakeConnectionParams()
require.NoError(t, err)
qr, err := cp.ToString()
require.NoError(t, err)
// Client reads QR code and parses the connection string
ccp := new(ConnectionParams)
err = ccp.FromString(qr)
require.NoError(t, err)
u, certPem, err := ccp.Generate()
require.NoError(t, err)
rootCAs, err := x509.SystemCertPool() rootCAs, err := x509.SystemCertPool()
require.NoError(t, err) require.NoError(t, err)
@ -71,7 +91,7 @@ func TestGetOutboundIPWithFullServerE2e(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
thing := hex.EncodeToString(b) thing := hex.EncodeToString(b)
response, err := client.Get(s.MakeBaseURL().String() + "/hello?say=" + thing) response, err := client.Get(u.String() + "/hello?say=" + thing)
require.NoError(t, err) require.NoError(t, err)
defer response.Body.Close() defer response.Body.Close()

View File

@ -1,16 +1,24 @@
package server package server
import ( import (
"crypto/ecdsa"
"crypto/tls" "crypto/tls"
"fmt"
"net"
) )
type PairingServer struct { type PairingServer struct {
Server Server
pk *ecdsa.PrivateKey
mode Mode
} }
type Config struct { type Config struct {
PK *ecdsa.PrivateKey
Cert *tls.Certificate Cert *tls.Certificate
Hostname string Hostname string
Mode Mode
} }
// NewPairingServer returns a *NewPairingServer init from the given *Config // NewPairingServer returns a *NewPairingServer init from the given *Config
@ -18,5 +26,35 @@ func NewPairingServer(config *Config) *PairingServer {
return &PairingServer{Server: NewServer( return &PairingServer{Server: NewServer(
config.Cert, config.Cert,
config.Hostname, config.Hostname,
)} ),
pk: config.PK,
mode: config.Mode}
}
// MakeConnectionParams generates a *ConnectionParams based on the Server's current state
func (s *PairingServer) MakeConnectionParams() (*ConnectionParams, error) {
switch {
case s.cert == nil:
return nil, fmt.Errorf("server has no cert set")
case s.cert.Leaf == nil:
return nil, fmt.Errorf("server cert has no Leaf set")
case s.cert.Leaf.NotBefore.IsZero():
return nil, fmt.Errorf("server cert Leaf has a zero value NotBefore")
}
netIP := net.ParseIP(s.hostname)
if netIP == nil {
return nil, fmt.Errorf("invalid ip address given '%s'", s.hostname)
}
netIP4 := netIP.To4()
if netIP4 != nil {
netIP = netIP4
}
if s.port == 0 {
return nil, fmt.Errorf("port is 0, listener is not yet set")
}
return NewConnectionParams(netIP, s.port, s.pk, s.cert.Leaf.NotBefore, s.mode), nil
} }

View File

@ -12,12 +12,16 @@ func TestServerURLSuite(t *testing.T) {
type ServerURLSuite struct { type ServerURLSuite struct {
suite.Suite suite.Suite
TestKeyComponents
server *MediaServer server *MediaServer
serverNoPort *MediaServer serverNoPort *MediaServer
pairingServer *PairingServer
} }
func (s *ServerURLSuite) SetupSuite() { func (s *ServerURLSuite) SetupSuite() {
s.SetupKeyComponents(s.T())
s.server = &MediaServer{Server: Server{ s.server = &MediaServer{Server: Server{
hostname: defaultIP.String(), hostname: defaultIP.String(),
port: 1337, port: 1337,