v1.0.0
This commit is contained in:
commit
6212cc3fbb
24 changed files with 3148 additions and 0 deletions
14
.github/workflows/gofmt.sh
vendored
Executable file
14
.github/workflows/gofmt.sh
vendored
Executable file
|
@ -0,0 +1,14 @@
|
|||
#!/bin/sh
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
rm -f ./gofmterr
|
||||
find . -iname '*.go' ! -name '*.pb.go' -exec "$0" {} \;
|
||||
[ -f ./gofmterr ] && exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OUT="$(./goimports -d "$1" | awk '{printf "%s%%0A",$0}')"
|
||||
if [ -n "$OUT" ]; then
|
||||
echo "::error file=$1::$OUT"
|
||||
touch ./gofmterr
|
||||
fi
|
39
.github/workflows/gofmt.yml
vendored
Normal file
39
.github/workflows/gofmt.yml
vendored
Normal file
|
@ -0,0 +1,39 @@
|
|||
name: gofmt
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/gofmt.yml"
|
||||
- ".github/workflows/gofmt.sh"
|
||||
- "**.go"
|
||||
|
||||
jobs:
|
||||
gofmt:
|
||||
name: Run gofmt
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.22'
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-gofmt1.22-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gofmt1.22-
|
||||
|
||||
- name: Install goimports
|
||||
run: |
|
||||
go get golang.org/x/tools/cmd/goimports
|
||||
go build golang.org/x/tools/cmd/goimports
|
||||
|
||||
- name: gofmt
|
||||
run: $GITHUB_WORKSPACE/.github/workflows/gofmt.sh
|
50
.github/workflows/test.yml
vendored
Normal file
50
.github/workflows/test.yml
vendored
Normal file
|
@ -0,0 +1,50 @@
|
|||
name: Build and test
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/test.yml"
|
||||
- "**Makefile"
|
||||
- "**.go"
|
||||
- "**.proto"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
|
||||
jobs:
|
||||
test-linux:
|
||||
name: Build and test on Ubuntu
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.22'
|
||||
id: go
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go1.22-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go1.22-
|
||||
|
||||
- name: Run unit tests
|
||||
run: make testvv
|
||||
env:
|
||||
TEST_FLAGS: -race
|
||||
|
||||
- name: Report failures to CT129
|
||||
if: ${{ always() && github.ref == 'refs/heads/main' }}
|
||||
uses: ravsamhq/notify-ct129-action@v2
|
||||
with:
|
||||
notification_title: "It seems that my sole purpose in this dismal existence is to spread the gloomy news of broken code and shattered dreams."
|
||||
footer: "I think you ought to know I'm feeling very depressed."
|
||||
status: ${{ job.status }}
|
||||
notify_when: 'failure'
|
||||
env:
|
||||
CT129_WEBHOOK_URL: ${{ secrets.CT129_REPORTING_WEBHOOK }}
|
22
Makefile
Normal file
22
Makefile
Normal file
|
@ -0,0 +1,22 @@
|
|||
vet:
|
||||
go vet -v ./...
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
test:
|
||||
$(TEST_ENV) go test $(TEST_FLAGS) $(shell go list ./...)
|
||||
|
||||
testv: TEST_FLAGS += -v
|
||||
testv: test
|
||||
|
||||
testvv: TEST_ENV += TEST_LOGS=1
|
||||
testvv: testv
|
||||
|
||||
testvvv: TEST_ENV += TEST_LOGS=2
|
||||
testvvv: testv
|
||||
|
||||
testvvvv: TEST_ENV += TEST_LOGS=3
|
||||
testvvvv: testv
|
||||
|
||||
.PHONY: vet fmt test testv testvv testvvv testvvvv
|
30
apiutil.go
Normal file
30
apiutil.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
package cmapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// InsertConfigPrivateKey takes a Cmesh YAML and a Cmesh PEM-formatted private key, and inserts the private key into
|
||||
// the config, overwriting any previous value stored in the config.
|
||||
func InsertConfigPrivateKey(config []byte, privkey []byte) ([]byte, error) {
|
||||
var y map[interface{}]interface{}
|
||||
if err := yaml.Unmarshal(config, &y); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %s", err)
|
||||
}
|
||||
|
||||
_, ok := y["pki"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("config is missing expected pki section")
|
||||
}
|
||||
|
||||
_, ok = y["pki"].(map[interface{}]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("config has unexpected value for pki section")
|
||||
}
|
||||
|
||||
y["pki"].(map[interface{}]interface{})["key"] = string(privkey)
|
||||
|
||||
return yaml.Marshal(y)
|
||||
}
|
25
apiutil_test.go
Normal file
25
apiutil_test.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package cmapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
func TestInsertConfigPrivateKey(t *testing.T) {
|
||||
cfg, err := InsertConfigPrivateKey([]byte(`
|
||||
pki: {}
|
||||
`), []byte("foobar"))
|
||||
require.NoError(t, err)
|
||||
|
||||
var y map[string]interface{}
|
||||
err = yaml.Unmarshal(cfg, &y)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "foobar", y["pki"].(map[interface{}]interface{})["key"])
|
||||
|
||||
cfg, err = InsertConfigPrivateKey([]byte(``), []byte("foobar"))
|
||||
require.Error(t, err)
|
||||
|
||||
}
|
536
client.go
Normal file
536
client.go
Normal file
|
@ -0,0 +1,536 @@
|
|||
// Package cmapi handles communication with the CT129 Solution cloud API server.
|
||||
package cmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.ct129.com/VPN/cmapi/keys"
|
||||
"git.ct129.com/VPN/cmapi/message"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Client communicates with the API server.
|
||||
type Client struct {
|
||||
dnServer string
|
||||
|
||||
client *http.Client
|
||||
streamingClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient returns new Client configured with the given useragent.
|
||||
// It also supports reading Proxy information from the environment.
|
||||
func NewClient(useragent string, dnServer string) *Client {
|
||||
return &Client{
|
||||
client: &http.Client{
|
||||
Timeout: 2 * time.Minute,
|
||||
Transport: &uaTransport{
|
||||
T: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
}).DialContext,
|
||||
},
|
||||
useragent: useragent,
|
||||
},
|
||||
},
|
||||
streamingClient: &http.Client{
|
||||
Timeout: 15 * time.Minute,
|
||||
Transport: &uaTransport{
|
||||
T: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
}).DialContext,
|
||||
},
|
||||
useragent: useragent,
|
||||
},
|
||||
},
|
||||
dnServer: dnServer,
|
||||
}
|
||||
}
|
||||
|
||||
// APIError wraps an error and contains the RequestID from the X-Request-ID
|
||||
// header of an API response. ReqID defaults to empty string if the header is
|
||||
// not in the response.
|
||||
type APIError struct {
|
||||
e error
|
||||
ReqID string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return e.e.Error()
|
||||
}
|
||||
|
||||
func (e *APIError) Unwrap() error {
|
||||
return e.e
|
||||
}
|
||||
|
||||
type InvalidCredentialsError struct{}
|
||||
|
||||
func (e InvalidCredentialsError) Error() string {
|
||||
return "invalid credentials"
|
||||
}
|
||||
|
||||
type EnrollMeta struct {
|
||||
OrganizationID string
|
||||
OrganizationName string
|
||||
}
|
||||
|
||||
// Enroll issues an enrollment request against the REST API using the given enrollment code, passing along a locally
|
||||
// generated DH X25519 public key to be signed by the CA, and an Ed 25519 public key for future API call authentication.
|
||||
// On success it returns the Cmesh config generated by the server, a Cmesh private key PEM to be inserted into the
|
||||
// config (see api.InsertConfigPrivateKey), credentials to be used in DNClient API requests, and a meta object
|
||||
// containing organization info.
|
||||
func (c *Client) Enroll(ctx context.Context, logger logrus.FieldLogger, code string) ([]byte, []byte, *keys.Credentials, *EnrollMeta, error) {
|
||||
logger.WithFields(logrus.Fields{"server": c.dnServer}).Debug("Making enrollment request to API")
|
||||
|
||||
// Generate newKeys for the enrollment request
|
||||
newKeys, err := keys.New()
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
hostEd25519PublicKeyPEM, err := newKeys.HostEd25519PublicKey.MarshalPEM()
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
hostP256PublicKeyPEM, err := newKeys.HostP256PublicKey.MarshalPEM()
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
// Make a request to the API with the enrollment code
|
||||
jv, err := json.Marshal(message.EnrollRequest{
|
||||
Code: code,
|
||||
CmeshPubkeyX25519: newKeys.CmeshX25519PublicKeyPEM,
|
||||
HostPubkeyEd25519: hostEd25519PublicKeyPEM,
|
||||
CmeshPubkeyP256: newKeys.CmeshP256PublicKeyPEM,
|
||||
HostPubkeyP256: hostP256PublicKeyPEM,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
enrollURL, err := url.JoinPath(c.dnServer, message.EnrollEndpoint)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", enrollURL, bytes.NewBuffer(jv))
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Log the request ID returned from the server
|
||||
reqID := resp.Header.Get("X-Request-ID")
|
||||
l := logger.WithFields(logrus.Fields{"statusCode": resp.StatusCode, "reqID": reqID})
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
l.Info("Enrollment request returned success code")
|
||||
} else {
|
||||
l.Error("Enrollment request returned error code")
|
||||
}
|
||||
|
||||
// Decode the response
|
||||
r := message.EnrollResponse{}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, &APIError{e: fmt.Errorf("error reading response body: %s", err), ReqID: reqID}
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
return nil, nil, nil, nil, &APIError{e: fmt.Errorf("error decoding JSON response: %s\nbody: %s", err, b), ReqID: reqID}
|
||||
}
|
||||
|
||||
// Check for any errors returned by the API
|
||||
if err := r.Errors.ToError(); err != nil {
|
||||
return nil, nil, nil, nil, &APIError{e: fmt.Errorf("unexpected error during enrollment: %v", err), ReqID: reqID}
|
||||
}
|
||||
|
||||
meta := &EnrollMeta{
|
||||
OrganizationID: r.Data.Organization.ID,
|
||||
OrganizationName: r.Data.Organization.Name,
|
||||
}
|
||||
|
||||
// Determine the private keys to save based on the network curve type
|
||||
var privkeyPEM []byte
|
||||
var privkey keys.PrivateKey
|
||||
switch r.Data.Network.Curve {
|
||||
case message.NetworkCurve25519:
|
||||
privkeyPEM = newKeys.CmeshX25519PrivateKeyPEM
|
||||
privkey = newKeys.HostEd25519PrivateKey
|
||||
case message.NetworkCurveP256:
|
||||
privkeyPEM = newKeys.CmeshP256PrivateKeyPEM
|
||||
privkey = newKeys.HostP256PrivateKey
|
||||
default:
|
||||
return nil, nil, nil, nil, &APIError{e: fmt.Errorf("unsupported curve type: %s", r.Data.Network.Curve), ReqID: reqID}
|
||||
}
|
||||
|
||||
trustedKeys, err := keys.TrustedKeysFromPEM(r.Data.TrustedKeys)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, &APIError{e: fmt.Errorf("failed to load trusted keys from bundle: %s", err), ReqID: reqID}
|
||||
}
|
||||
|
||||
creds := &keys.Credentials{
|
||||
HostID: r.Data.HostID,
|
||||
PrivateKey: privkey,
|
||||
Counter: r.Data.Counter,
|
||||
TrustedKeys: trustedKeys,
|
||||
}
|
||||
return r.Data.Config, privkeyPEM, creds, meta, nil
|
||||
}
|
||||
|
||||
// CheckForUpdate sends a signed message to the DNClient API to learn if there is a new configuration available.
|
||||
func (c *Client) CheckForUpdate(ctx context.Context, creds keys.Credentials) (bool, error) {
|
||||
respBody, err := c.postDNClient(ctx, message.CheckForUpdate, nil, creds.HostID, creds.Counter, creds.PrivateKey)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to post message to dnclient api: %w", err)
|
||||
}
|
||||
result := message.CheckForUpdateResponseWrapper{}
|
||||
err = json.Unmarshal(respBody, &result)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to interpret API response: %s", err)
|
||||
}
|
||||
return result.Data.UpdateAvailable, nil
|
||||
}
|
||||
|
||||
// LongPollWait sends a signed message to a DNClient API endpoint that will block, returning only
|
||||
// if there is an action the client should take before the timeout (config updates, debug commands)
|
||||
func (c *Client) LongPollWait(ctx context.Context, creds keys.Credentials, supportedActions []string) (*message.LongPollWaitResponse, error) {
|
||||
value, err := json.Marshal(message.LongPollWaitRequest{
|
||||
SupportedActions: supportedActions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal DNClient message: %s", err)
|
||||
}
|
||||
|
||||
respBody, err := c.postDNClient(ctx, message.LongPollWait, value, creds.HostID, creds.Counter, creds.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to post message to dnclient api: %w", err)
|
||||
}
|
||||
result := message.LongPollWaitResponseWrapper{}
|
||||
err = json.Unmarshal(respBody, &result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to interpret API response: %s", err)
|
||||
}
|
||||
return &result.Data, nil
|
||||
}
|
||||
|
||||
// DoUpdate sends a signed message to the DNClient API to fetch the new configuration update. During this call new keys
|
||||
// are generated both for Cmesh and DNClient API communication. If the API response is successful, the new configuration
|
||||
// is returned along with the new Cmesh private key PEM and new DNClient API credentials.
|
||||
//
|
||||
// See cmapi.InsertConfigPrivateKey for how to insert the new Cmesh private key into the configuration.
|
||||
func (c *Client) DoUpdate(ctx context.Context, creds keys.Credentials) ([]byte, []byte, *keys.Credentials, error) {
|
||||
// Rotate keys
|
||||
var cmeshPrivkeyPEM []byte // ECDH
|
||||
var hostPrivkey keys.PrivateKey // ECDSA
|
||||
|
||||
newKeys, err := keys.New()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to generate new keys: %s", err)
|
||||
}
|
||||
|
||||
msg := message.DoUpdateRequest{
|
||||
Nonce: nonce(),
|
||||
}
|
||||
|
||||
// Set the correct keypair based on the current private key type
|
||||
switch creds.PrivateKey.Unwrap().(type) {
|
||||
case ed25519.PrivateKey:
|
||||
hostPubkeyPEM, err := newKeys.HostEd25519PublicKey.MarshalPEM()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to marshal Ed25519 public key: %s", err)
|
||||
}
|
||||
hostPrivkey = newKeys.HostEd25519PrivateKey
|
||||
cmeshPrivkeyPEM = newKeys.CmeshX25519PrivateKeyPEM
|
||||
msg.HostPubkeyEd25519 = hostPubkeyPEM
|
||||
msg.CmeshPubkeyX25519 = newKeys.CmeshX25519PublicKeyPEM
|
||||
case *ecdsa.PrivateKey:
|
||||
hostPubkeyPEM, err := newKeys.HostP256PublicKey.MarshalPEM()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to marshal P256 public key: %s", err)
|
||||
}
|
||||
hostPrivkey = newKeys.HostP256PrivateKey
|
||||
cmeshPrivkeyPEM = newKeys.CmeshP256PrivateKeyPEM
|
||||
msg.HostPubkeyP256 = hostPubkeyPEM
|
||||
msg.CmeshPubkeyP256 = newKeys.CmeshP256PublicKeyPEM
|
||||
}
|
||||
|
||||
blob, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to marshal DNClient message: %s", err)
|
||||
}
|
||||
|
||||
// Make API call
|
||||
resp, err := c.postDNClient(ctx, message.DoUpdate, blob, creds.HostID, creds.Counter, creds.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to make API call to CT129 Solution: %w", err)
|
||||
}
|
||||
resultWrapper := message.SignedResponseWrapper{}
|
||||
err = json.Unmarshal(resp, &resultWrapper)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to unmarshal signed response wrapper: %s", err)
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
valid := false
|
||||
for _, caPubkey := range creds.TrustedKeys {
|
||||
if caPubkey.Verify(resultWrapper.Data.Message, resultWrapper.Data.Signature) {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return nil, nil, nil, fmt.Errorf("failed to verify signed API result")
|
||||
}
|
||||
|
||||
// Consume the verified message
|
||||
result := message.DoUpdateResponse{}
|
||||
err = json.Unmarshal(resultWrapper.Data.Message, &result)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to unmarshal response (%s): %s", resultWrapper.Data.Message, err)
|
||||
}
|
||||
|
||||
// Verify the nonce
|
||||
if !bytes.Equal(result.Nonce, msg.Nonce) {
|
||||
return nil, nil, nil, fmt.Errorf("nonce mismatch between request (%s) and response (%s)", msg.Nonce, result.Nonce)
|
||||
}
|
||||
|
||||
// Verify the counter
|
||||
if result.Counter <= creds.Counter {
|
||||
return nil, nil, nil, fmt.Errorf("counter in request (%d) should be less than counter in response (%d)", creds.Counter, result.Counter)
|
||||
}
|
||||
|
||||
trustedKeys, err := keys.TrustedKeysFromPEM(result.TrustedKeys)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to load trusted keys from bundle: %s", err)
|
||||
}
|
||||
|
||||
newCreds := &keys.Credentials{
|
||||
HostID: creds.HostID,
|
||||
Counter: result.Counter,
|
||||
PrivateKey: hostPrivkey,
|
||||
TrustedKeys: trustedKeys,
|
||||
}
|
||||
|
||||
return result.Config, cmeshPrivkeyPEM, newCreds, nil
|
||||
}
|
||||
|
||||
func (c *Client) CommandResponse(ctx context.Context, creds keys.Credentials, responseToken string, response any) error {
|
||||
value, err := json.Marshal(message.CommandResponseRequest{
|
||||
ResponseToken: responseToken,
|
||||
Response: response,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal DNClient message: %s", err)
|
||||
}
|
||||
|
||||
_, err = c.postDNClient(ctx, message.CommandResponse, value, creds.HostID, creds.Counter, creds.PrivateKey)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) StreamCommandResponse(ctx context.Context, creds keys.Credentials, responseToken string) (*StreamController, error) {
|
||||
value, err := json.Marshal(message.CommandResponseRequest{
|
||||
ResponseToken: responseToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal DNClient message: %s", err)
|
||||
}
|
||||
|
||||
return c.streamingPostDNClient(ctx, message.CommandResponse, value, creds.HostID, creds.Counter, creds.PrivateKey)
|
||||
}
|
||||
|
||||
// streamingPostDNClient wraps and signs the given dnclientRequestWrapper message, and makes a streaming API call.
|
||||
// On success, it returns a StreamController to interact with the request. On error, the error is returned.
|
||||
func (c *Client) streamingPostDNClient(ctx context.Context, reqType string, value []byte, hostID string, counter uint, privkey keys.PrivateKey) (*StreamController, error) {
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
postBody, err := SignRequestV1(reqType, value, hostID, counter, privkey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pbb := bytes.NewBuffer(postBody)
|
||||
|
||||
endpointV1URL, err := url.JoinPath(c.dnServer, message.EndpointV1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpointV1URL, io.MultiReader(pbb, pr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
sc := &StreamController{w: pw, done: done}
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
resp, err := c.streamingClient.Do(req)
|
||||
if err != nil {
|
||||
sc.err.Store(fmt.Errorf("failed to call dnclient endpoint: %w", err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
sc.err.Store(fmt.Errorf("failed to read the response body: %s", err))
|
||||
}
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
sc.respBytes = respBody
|
||||
case http.StatusUnauthorized:
|
||||
sc.err.Store(InvalidCredentialsError{})
|
||||
default:
|
||||
var errors struct {
|
||||
Errors message.APIErrors
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &errors); err != nil {
|
||||
sc.err.Store(fmt.Errorf("dnclient endpoint returned bad status code '%d', body: %s", resp.StatusCode, respBody))
|
||||
} else {
|
||||
sc.err.Store(errors.Errors.ToError())
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
// postDNClient wraps and signs the given dnclientRequestWrapper message, and makes the API call.
|
||||
// On success, it returns the response message body. On error, the error is returned.
|
||||
func (c *Client) postDNClient(ctx context.Context, reqType string, value []byte, hostID string, counter uint, privkey keys.PrivateKey) ([]byte, error) {
|
||||
postBody, err := SignRequestV1(reqType, value, hostID, counter, privkey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpointV1URL, err := url.JoinPath(c.dnServer, message.EndpointV1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpointV1URL, bytes.NewReader(postBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call dnclient endpoint: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read the response body: %s", err)
|
||||
}
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return respBody, nil
|
||||
case http.StatusUnauthorized:
|
||||
return nil, InvalidCredentialsError{}
|
||||
default:
|
||||
var errors struct {
|
||||
Errors message.APIErrors
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &errors); err != nil {
|
||||
return nil, fmt.Errorf("dnclient endpoint returned bad status code '%d', body: %s", resp.StatusCode, respBody)
|
||||
}
|
||||
return nil, errors.Errors.ToError()
|
||||
}
|
||||
}
|
||||
|
||||
// StreamController is used for interacting with streaming requests to the API.
|
||||
//
|
||||
// When a streaming request is started in a background goroutine, a StreamController is returned to the caller to allow
|
||||
// writing to the request body. The request will be sent when the caller closes the StreamController. The response body
|
||||
// can be read by calling ResponseBytes, which will block until the response is received.
|
||||
type StreamController struct {
|
||||
w *io.PipeWriter
|
||||
respBytes []byte
|
||||
err atomic.Value
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Err returns any error that occurred during the streaming request. If the request was successful, Err will return nil.
|
||||
// Err should be called after Close to ensure the request has completed.
|
||||
func (sc *StreamController) Err() error {
|
||||
err := sc.err.Load()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return err.(error)
|
||||
}
|
||||
|
||||
// Write implements the io.Writer interface for StreamController. It writes to the request body. If the StreamController
|
||||
// has already encountered an error, it will be returned and nothing will be written.
|
||||
func (sc *StreamController) Write(p []byte) (int, error) {
|
||||
if sc.Err() != nil {
|
||||
return 0, sc.Err()
|
||||
}
|
||||
|
||||
n, err := sc.w.Write(p)
|
||||
if err != nil {
|
||||
sc.err.Store(err)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close closes the StreamController, signaling that the request body is complete and the response can be read.
|
||||
func (sc *StreamController) Close() error {
|
||||
err := sc.w.Close()
|
||||
<-sc.done
|
||||
return err
|
||||
}
|
||||
|
||||
// ResponseBytes blocks until the response is received, then returns the response body. If an error occurred during the
|
||||
// request, ResponseBytes will return the error.
|
||||
func (sc *StreamController) ResponseBytes() ([]byte, error) {
|
||||
<-sc.done
|
||||
if sc.Err() != nil {
|
||||
return nil, sc.Err()
|
||||
}
|
||||
return sc.respBytes, nil
|
||||
}
|
||||
|
||||
// uaTransport wraps an http.RoundTripper and sets the User-Agent header on all requests.
|
||||
type uaTransport struct {
|
||||
useragent string
|
||||
T http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", t.useragent)
|
||||
return t.T.RoundTrip(req)
|
||||
}
|
||||
|
||||
func nonce() []byte {
|
||||
nonce := make([]byte, 16)
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return nonce
|
||||
}
|
831
client_test.go
Normal file
831
client_test.go
Normal file
|
@ -0,0 +1,831 @@
|
|||
package cmapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ct129.com/VPN/cmapi/cmapitest"
|
||||
"git.ct129.com/VPN/cmapi/internal/testutil"
|
||||
"git.ct129.com/VPN/cmapi/keys"
|
||||
"git.ct129.com/VPN/cmapi/message"
|
||||
"github.com/sirupsen/logrus"
|
||||
"git.ct129.com/VPN/cmesh/cert"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type m map[string]interface{}
|
||||
|
||||
func TestEnroll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
useragent := "dnclientUnitTests/1.0.0 (not a real client)"
|
||||
ts := cmapitest.NewServer(useragent)
|
||||
client := NewClient(useragent, ts.URL)
|
||||
// attempting to defer ts.Close() will trigger early due to parallel testing - use T.Cleanup instead
|
||||
t.Cleanup(func() { ts.Close() })
|
||||
|
||||
// Happy path enrollment
|
||||
code := "abcdef"
|
||||
hostID := "foobar"
|
||||
orgID := "foobaz"
|
||||
orgName := "foobar's foo org"
|
||||
netID := "qux"
|
||||
netCurve := message.NetworkCurve25519
|
||||
counter := uint(5)
|
||||
ca, _ := cmapitest.CmeshCACert()
|
||||
caPEM, err := ca.MarshalToPEM()
|
||||
require.NoError(t, err)
|
||||
|
||||
ts.ExpectEnrollment(code, message.NetworkCurve25519, func(req message.EnrollRequest) []byte {
|
||||
cfg, err := yaml.Marshal(m{
|
||||
// we need to send this or we'll get an error from the api client
|
||||
"pki": m{"ca": string(caPEM)},
|
||||
// here we reflect values back to the client for test purposes
|
||||
"test": m{"code": req.Code, "dhPubkey": req.CmeshPubkeyX25519},
|
||||
})
|
||||
if err != nil {
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Errors: message.APIErrors{{
|
||||
Code: "ERR_FAILED_TO_MARSHAL_YAML",
|
||||
Message: "failed to marshal test response config",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Data: message.EnrollResponseData{
|
||||
HostID: hostID,
|
||||
Counter: counter,
|
||||
Config: cfg,
|
||||
TrustedKeys: marshalCAPublicKey(ca.Details.Curve, ca.Details.PublicKey),
|
||||
Organization: message.EnrollResponseDataOrg{
|
||||
ID: orgID,
|
||||
Name: orgName,
|
||||
},
|
||||
Network: message.EnrollResponseDataNetwork{
|
||||
ID: netID,
|
||||
Curve: netCurve,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
cfg, pkey, creds, meta, err := client.Enroll(ctx, testutil.NewTestLogger(), code)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, ts.Errors())
|
||||
assert.Equal(t, 0, ts.RequestsRemaining())
|
||||
|
||||
tk, err := keys.NewTrustedKey(ed25519.PublicKey(ca.Details.PublicKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, hostID, creds.HostID)
|
||||
assert.Equal(t, counter, creds.Counter)
|
||||
assert.Equal(t, []keys.TrustedKey{tk}, creds.TrustedKeys)
|
||||
assert.NotEmpty(t, creds.PrivateKey)
|
||||
assert.NotEmpty(t, pkey)
|
||||
|
||||
var y struct {
|
||||
PKI struct {
|
||||
Key string `yaml:"key"`
|
||||
} `yaml:"pki"`
|
||||
Test struct {
|
||||
Code string `yaml:"code"`
|
||||
DHPubkey []byte `yaml:"dhPubkey"`
|
||||
} `yaml:"test"`
|
||||
}
|
||||
err = yaml.Unmarshal(cfg, &y)
|
||||
require.NoError(t, err)
|
||||
_, rest, err := cert.UnmarshalX25519PublicKey(y.Test.DHPubkey)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, rest, 0)
|
||||
assert.Equal(t, code, y.Test.Code)
|
||||
|
||||
// ensure private key was not inserted into config
|
||||
assert.Empty(t, y.PKI.Key)
|
||||
|
||||
// test meta
|
||||
assert.Equal(t, orgID, meta.OrganizationID)
|
||||
assert.Equal(t, orgName, meta.OrganizationName)
|
||||
|
||||
// Test error handling
|
||||
errorMsg := "invalid enrollment code"
|
||||
ts.ExpectEnrollment(code, message.NetworkCurve25519, func(req message.EnrollRequest) []byte {
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Errors: message.APIErrors{{
|
||||
Code: "ERR_INVALID_ENROLLMENT_CODE",
|
||||
Message: errorMsg,
|
||||
}},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
cfg, pkey, creds, meta, err = client.Enroll(ctx, testutil.NewTestLogger(), code)
|
||||
require.Errorf(t, err, fmt.Sprintf("unexpected error during enrollment: %s", errorMsg))
|
||||
|
||||
assert.Nil(t, cfg)
|
||||
assert.Nil(t, pkey)
|
||||
assert.Nil(t, creds)
|
||||
assert.Nil(t, meta)
|
||||
apiError := &APIError{}
|
||||
reqIDErrPresent := errors.As(err, &apiError)
|
||||
require.True(t, reqIDErrPresent)
|
||||
assert.Equal(t, apiError.ReqID, "SupaDoopaRequestIdentifier")
|
||||
}
|
||||
|
||||
func TestDoUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
useragent := "testClient"
|
||||
ts := cmapitest.NewServer(useragent)
|
||||
t.Cleanup(func() { ts.Close() })
|
||||
|
||||
ca, caPrivkey := cmapitest.CmeshCACert()
|
||||
caPEM, err := ca.MarshalToPEM()
|
||||
require.NoError(t, err)
|
||||
|
||||
c := NewClient(useragent, ts.URL)
|
||||
|
||||
code := "foobar"
|
||||
ts.ExpectEnrollment(code, message.NetworkCurve25519, func(req message.EnrollRequest) []byte {
|
||||
cfg, err := yaml.Marshal(m{
|
||||
// we need to send this or we'll get an error from the api client
|
||||
"pki": m{"ca": string(caPEM)},
|
||||
// here we reflect values back to the client for test purposes
|
||||
"test": m{"code": req.Code, "dhPubkey": req.CmeshPubkeyX25519},
|
||||
})
|
||||
if err != nil {
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Errors: message.APIErrors{{
|
||||
Code: "ERR_FAILED_TO_MARSHAL_YAML",
|
||||
Message: "failed to marshal test response config",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Data: message.EnrollResponseData{
|
||||
HostID: "foobar",
|
||||
Counter: 1,
|
||||
Config: cfg,
|
||||
TrustedKeys: marshalCAPublicKey(ca.Details.Curve, ca.Details.PublicKey),
|
||||
Organization: message.EnrollResponseDataOrg{
|
||||
ID: "foobaz",
|
||||
Name: "foobar's foo org",
|
||||
},
|
||||
Network: message.EnrollResponseDataNetwork{
|
||||
ID: "qux",
|
||||
Curve: message.NetworkCurve25519,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
config, pkey, creds, _, err := c.Enroll(ctx, testutil.NewTestLogger(), "foobar")
|
||||
require.NoError(t, err)
|
||||
|
||||
// convert privkey to private key
|
||||
pubkey, err := keys.MarshalHostEd25519PublicKey(creds.PrivateKey.Public().Unwrap().(ed25519.PublicKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
// make sure all credential values were set
|
||||
assert.NotEmpty(t, creds.HostID)
|
||||
assert.NotEmpty(t, creds.PrivateKey)
|
||||
assert.NotEmpty(t, creds.TrustedKeys)
|
||||
assert.NotEmpty(t, creds.Counter)
|
||||
|
||||
// make sure we got a config back
|
||||
assert.NotEmpty(t, config)
|
||||
assert.NotEmpty(t, pkey)
|
||||
|
||||
// Invalid request signature should return a specific error
|
||||
ts.ExpectRequest(message.CheckForUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
|
||||
return []byte("")
|
||||
})
|
||||
|
||||
// Create a new, invalid requesting authentication key
|
||||
nk, err := keys.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
invalidCreds := keys.Credentials{
|
||||
HostID: creds.HostID,
|
||||
PrivateKey: nk.HostEd25519PrivateKey,
|
||||
Counter: creds.Counter,
|
||||
TrustedKeys: creds.TrustedKeys,
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
_, err = c.CheckForUpdate(ctx, invalidCreds)
|
||||
assert.Error(t, err)
|
||||
invalidCredsErrorType := InvalidCredentialsError{}
|
||||
assert.ErrorAs(t, err, &invalidCredsErrorType)
|
||||
serverErrs := ts.Errors() // This consumes/resets the server errors
|
||||
require.Len(t, serverErrs, 1)
|
||||
|
||||
// Invalid signature
|
||||
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
|
||||
newConfigResponse := message.DoUpdateResponse{
|
||||
Config: cmapitest.CmeshCfg(caPEM),
|
||||
Counter: 2,
|
||||
Nonce: cmapitest.GetNonce(r),
|
||||
}
|
||||
rawRes := jsonMarshal(newConfigResponse)
|
||||
|
||||
nk, err := keys.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
// XXX the mock server will update the ed pubkey for us, but this is problematic because
|
||||
// we are rejecting the update. reset the key
|
||||
err = ts.SetEdPubkey(pubkey)
|
||||
require.NoError(t, err)
|
||||
|
||||
sig, err := nk.HostEd25519PrivateKey.Sign(rawRes)
|
||||
require.NoError(t, err)
|
||||
|
||||
return jsonMarshal(message.SignedResponseWrapper{
|
||||
Data: message.SignedResponse{
|
||||
Version: 1,
|
||||
Message: rawRes,
|
||||
Signature: sig,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
cfg, pkey, newCreds, err := c.DoUpdate(ctx, *creds)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, ts.Errors())
|
||||
assert.Equal(t, 0, ts.RequestsRemaining())
|
||||
require.Nil(t, newCreds)
|
||||
require.Nil(t, cfg)
|
||||
require.Nil(t, pkey)
|
||||
|
||||
// Invalid counter
|
||||
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
|
||||
newConfigResponse := message.DoUpdateResponse{
|
||||
Config: cmapitest.CmeshCfg(caPEM),
|
||||
Counter: 0,
|
||||
Nonce: cmapitest.GetNonce(r),
|
||||
}
|
||||
rawRes := jsonMarshal(newConfigResponse)
|
||||
|
||||
// XXX the mock server will update the ed pubkey for us, but this is problematic because
|
||||
// we are rejecting the update. reset the key
|
||||
err := ts.SetEdPubkey(pubkey)
|
||||
require.NoError(t, err)
|
||||
|
||||
return jsonMarshal(message.SignedResponseWrapper{
|
||||
Data: message.SignedResponse{
|
||||
Version: 1,
|
||||
Message: rawRes,
|
||||
Signature: ed25519.Sign(caPrivkey, rawRes),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
cfg, pkey, newCreds, err = c.DoUpdate(ctx, *creds)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, ts.Errors())
|
||||
assert.Equal(t, 0, ts.RequestsRemaining())
|
||||
require.Nil(t, newCreds)
|
||||
require.Nil(t, cfg)
|
||||
require.Nil(t, pkey)
|
||||
|
||||
// This time sign the response with the correct CA key.
|
||||
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
|
||||
newConfigResponse := message.DoUpdateResponse{
|
||||
Config: cmapitest.CmeshCfg(caPEM),
|
||||
Counter: 3,
|
||||
Nonce: cmapitest.GetNonce(r),
|
||||
}
|
||||
rawRes := jsonMarshal(newConfigResponse)
|
||||
|
||||
return jsonMarshal(message.SignedResponseWrapper{
|
||||
Data: message.SignedResponse{
|
||||
Version: 1,
|
||||
Message: rawRes,
|
||||
Signature: ed25519.Sign(caPrivkey, rawRes),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
_, _, _, err = c.DoUpdate(ctx, *creds)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, ts.Errors())
|
||||
assert.Equal(t, 0, ts.RequestsRemaining())
|
||||
|
||||
}
|
||||
|
||||
func TestDoUpdate_P256(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
useragent := "testClient"
|
||||
ts := cmapitest.NewServer(useragent)
|
||||
t.Cleanup(func() { ts.Close() })
|
||||
|
||||
ca, caPrivkey := cmapitest.CmeshCACertP256()
|
||||
caPEM, err := ca.MarshalToPEM()
|
||||
require.NoError(t, err)
|
||||
|
||||
c := NewClient(useragent, ts.URL)
|
||||
|
||||
code := "foobar"
|
||||
ts.ExpectEnrollment(code, message.NetworkCurveP256, func(req message.EnrollRequest) []byte {
|
||||
cfg, err := yaml.Marshal(m{
|
||||
// we need to send this or we'll get an error from the api client
|
||||
"pki": m{"ca": string(caPEM)},
|
||||
// here we reflect values back to the client for test purposes
|
||||
"test": m{"code": req.Code, "p256Pubkey": req.CmeshPubkeyP256},
|
||||
})
|
||||
if err != nil {
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Errors: message.APIErrors{{
|
||||
Code: "ERR_FAILED_TO_MARSHAL_YAML",
|
||||
Message: "failed to marshal test response config",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Data: message.EnrollResponseData{
|
||||
HostID: "foobar",
|
||||
Counter: 1,
|
||||
Config: cfg,
|
||||
TrustedKeys: marshalCAPublicKey(ca.Details.Curve, ca.Details.PublicKey),
|
||||
Organization: message.EnrollResponseDataOrg{
|
||||
ID: "foobaz",
|
||||
Name: "foobar's foo org",
|
||||
},
|
||||
Network: message.EnrollResponseDataNetwork{
|
||||
ID: "qux",
|
||||
Curve: message.NetworkCurveP256,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
config, pkey, creds, _, err := c.Enroll(ctx, testutil.NewTestLogger(), "foobar")
|
||||
require.NoError(t, err)
|
||||
|
||||
// convert privkey to private key
|
||||
pubkey, err := keys.MarshalHostP256PublicKey(creds.PrivateKey.Public().Unwrap().(*ecdsa.PublicKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
// make sure all credential values were set
|
||||
assert.NotEmpty(t, creds.HostID)
|
||||
assert.NotEmpty(t, creds.PrivateKey)
|
||||
assert.NotEmpty(t, creds.TrustedKeys)
|
||||
assert.NotEmpty(t, creds.Counter)
|
||||
|
||||
// make sure we got a config back
|
||||
assert.NotEmpty(t, config)
|
||||
assert.NotEmpty(t, pkey)
|
||||
|
||||
// Invalid request signature should return a specific error
|
||||
ts.ExpectRequest(message.CheckForUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
|
||||
return []byte("")
|
||||
})
|
||||
|
||||
// Create a new, invalid requesting authentication key
|
||||
nk, err := keys.New()
|
||||
require.NoError(t, err)
|
||||
invalidCreds := keys.Credentials{
|
||||
HostID: creds.HostID,
|
||||
PrivateKey: nk.HostP256PrivateKey,
|
||||
Counter: creds.Counter,
|
||||
TrustedKeys: creds.TrustedKeys,
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
_, err = c.CheckForUpdate(ctx, invalidCreds)
|
||||
assert.Error(t, err)
|
||||
invalidCredsErrorType := InvalidCredentialsError{}
|
||||
assert.ErrorAs(t, err, &invalidCredsErrorType)
|
||||
serverErrs := ts.Errors() // This consumes/resets the server errors
|
||||
require.Len(t, serverErrs, 1)
|
||||
|
||||
// Invalid signature
|
||||
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
|
||||
newConfigResponse := message.DoUpdateResponse{
|
||||
Config: cmapitest.CmeshCfg(caPEM),
|
||||
Counter: 2,
|
||||
Nonce: cmapitest.GetNonce(r),
|
||||
}
|
||||
rawRes := jsonMarshal(newConfigResponse)
|
||||
|
||||
nk, err := keys.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
// XXX the mock server will update the ed pubkey for us, but this is problematic because
|
||||
// we are rejecting the update. reset the key
|
||||
err = ts.SetP256Pubkey(pubkey)
|
||||
require.NoError(t, err)
|
||||
|
||||
sig, err := nk.HostP256PrivateKey.Sign(rawRes)
|
||||
if err != nil {
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Errors: message.APIErrors{{
|
||||
Code: "ERR_FAILED_TO_SIGN_MESSAGE",
|
||||
Message: "failed to sign message",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return jsonMarshal(message.SignedResponseWrapper{
|
||||
Data: message.SignedResponse{
|
||||
Version: 1,
|
||||
Message: rawRes,
|
||||
Signature: sig,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
cfg, pkey, newCreds, err := c.DoUpdate(ctx, *creds)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, ts.Errors())
|
||||
assert.Equal(t, 0, ts.RequestsRemaining())
|
||||
require.Nil(t, newCreds)
|
||||
require.Nil(t, cfg)
|
||||
require.Nil(t, pkey)
|
||||
|
||||
// Invalid counter
|
||||
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
|
||||
newConfigResponse := message.DoUpdateResponse{
|
||||
Config: cmapitest.CmeshCfg(caPEM),
|
||||
Counter: 0,
|
||||
Nonce: cmapitest.GetNonce(r),
|
||||
}
|
||||
rawRes := jsonMarshal(newConfigResponse)
|
||||
|
||||
// XXX the mock server will update the host pubkey for us, but this is problematic because
|
||||
// we are rejecting the update. reset the key
|
||||
err := ts.SetP256Pubkey(pubkey)
|
||||
require.NoError(t, err)
|
||||
|
||||
hashed := sha256.Sum256(rawRes)
|
||||
sig, err := ecdsa.SignASN1(rand.Reader, caPrivkey, hashed[:])
|
||||
if err != nil {
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Errors: message.APIErrors{{
|
||||
Code: "ERR_FAILED_TO_SIGN_MESSAGE",
|
||||
Message: "failed to sign message",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return jsonMarshal(message.SignedResponseWrapper{
|
||||
Data: message.SignedResponse{
|
||||
Version: 1,
|
||||
Message: rawRes,
|
||||
Signature: sig,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
cfg, pkey, newCreds, err = c.DoUpdate(ctx, *creds)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, ts.Errors())
|
||||
assert.Equal(t, 0, ts.RequestsRemaining())
|
||||
require.Nil(t, newCreds)
|
||||
require.Nil(t, cfg)
|
||||
require.Nil(t, pkey)
|
||||
|
||||
// This time sign the response with the correct CA key.
|
||||
ts.ExpectRequest(message.DoUpdate, http.StatusOK, func(r message.RequestWrapper) []byte {
|
||||
newConfigResponse := message.DoUpdateResponse{
|
||||
Config: cmapitest.CmeshCfg(caPEM),
|
||||
Counter: 3,
|
||||
Nonce: cmapitest.GetNonce(r),
|
||||
}
|
||||
rawRes := jsonMarshal(newConfigResponse)
|
||||
hashed := sha256.Sum256(rawRes)
|
||||
sig, err := ecdsa.SignASN1(rand.Reader, caPrivkey, hashed[:])
|
||||
if err != nil {
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Errors: message.APIErrors{{
|
||||
Code: "ERR_FAILED_TO_SIGN_MESSAGE",
|
||||
Message: "failed to sign message",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return jsonMarshal(message.SignedResponseWrapper{
|
||||
Data: message.SignedResponse{
|
||||
Version: 1,
|
||||
Message: rawRes,
|
||||
Signature: sig,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
_, _, _, err = c.DoUpdate(ctx, *creds)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, ts.Errors())
|
||||
assert.Equal(t, 0, ts.RequestsRemaining())
|
||||
|
||||
}
|
||||
|
||||
func TestCommandResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
useragent := "testClient"
|
||||
ts := cmapitest.NewServer(useragent)
|
||||
t.Cleanup(func() { ts.Close() })
|
||||
|
||||
ca, _ := cmapitest.CmeshCACert()
|
||||
caPEM, err := ca.MarshalToPEM()
|
||||
require.NoError(t, err)
|
||||
|
||||
c := NewClient(useragent, ts.URL)
|
||||
|
||||
code := "foobar"
|
||||
ts.ExpectEnrollment(code, message.NetworkCurve25519, func(req message.EnrollRequest) []byte {
|
||||
cfg, err := yaml.Marshal(m{
|
||||
// we need to send this or we'll get an error from the api client
|
||||
"pki": m{"ca": string(caPEM)},
|
||||
// here we reflect values back to the client for test purposes
|
||||
"test": m{"code": req.Code, "dhPubkey": req.CmeshPubkeyX25519},
|
||||
})
|
||||
if err != nil {
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Errors: message.APIErrors{{
|
||||
Code: "ERR_FAILED_TO_MARSHAL_YAML",
|
||||
Message: "failed to marshal test response config",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Data: message.EnrollResponseData{
|
||||
HostID: "foobar",
|
||||
Counter: 1,
|
||||
Config: cfg,
|
||||
TrustedKeys: marshalCAPublicKey(ca.Details.Curve, ca.Details.PublicKey),
|
||||
Organization: message.EnrollResponseDataOrg{
|
||||
ID: "foobaz",
|
||||
Name: "foobar's foo org",
|
||||
},
|
||||
Network: message.EnrollResponseDataNetwork{
|
||||
ID: "qux",
|
||||
Curve: message.NetworkCurve25519,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
config, pkey, creds, _, err := c.Enroll(ctx, testutil.NewTestLogger(), "foobar")
|
||||
require.NoError(t, err)
|
||||
|
||||
// make sure all credential values were set
|
||||
assert.NotEmpty(t, creds.HostID)
|
||||
assert.NotEmpty(t, creds.PrivateKey)
|
||||
assert.NotEmpty(t, creds.TrustedKeys)
|
||||
assert.NotEmpty(t, creds.Counter)
|
||||
|
||||
// make sure we got a config back
|
||||
assert.NotEmpty(t, config)
|
||||
assert.NotEmpty(t, pkey)
|
||||
|
||||
// This time sign the response with the correct CA key.
|
||||
responseToken := "abc123"
|
||||
res := map[string]any{"msg": "Hello, world!"}
|
||||
ts.ExpectRequest(message.CommandResponse, http.StatusOK, func(r message.RequestWrapper) []byte {
|
||||
var val map[string]any
|
||||
err := json.Unmarshal(r.Value, &val)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, val, "responseToken")
|
||||
require.Equal(t, responseToken, val["responseToken"])
|
||||
require.Contains(t, val, "response")
|
||||
require.Equal(t, res, val["response"])
|
||||
return jsonMarshal(struct{}{})
|
||||
})
|
||||
|
||||
err = c.CommandResponse(context.Background(), *creds, responseToken, res)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test error handling
|
||||
errorMsg := "sample error"
|
||||
ts.ExpectRequest(message.CommandResponse, http.StatusBadRequest, func(r message.RequestWrapper) []byte {
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Errors: message.APIErrors{{
|
||||
Code: "ERR_INVALID_VALUE",
|
||||
Message: errorMsg,
|
||||
}},
|
||||
})
|
||||
})
|
||||
|
||||
err = c.CommandResponse(context.Background(), *creds, "responseToken", map[string]any{"msg": "Hello, world!"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestStreamCommandResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
useragent := "testClient"
|
||||
ts := cmapitest.NewServer(useragent)
|
||||
t.Cleanup(func() { ts.Close() })
|
||||
|
||||
ca, _ := cmapitest.CmeshCACert()
|
||||
caPEM, err := ca.MarshalToPEM()
|
||||
require.NoError(t, err)
|
||||
|
||||
c := NewClient(useragent, ts.URL)
|
||||
|
||||
code := "foobar"
|
||||
ts.ExpectEnrollment(code, message.NetworkCurve25519, func(req message.EnrollRequest) []byte {
|
||||
cfg, err := yaml.Marshal(m{
|
||||
// we need to send this or we'll get an error from the api client
|
||||
"pki": m{"ca": string(caPEM)},
|
||||
// here we reflect values back to the client for test purposes
|
||||
"test": m{"code": req.Code, "dhPubkey": req.CmeshPubkeyX25519},
|
||||
})
|
||||
if err != nil {
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Errors: message.APIErrors{{
|
||||
Code: "ERR_FAILED_TO_MARSHAL_YAML",
|
||||
Message: "failed to marshal test response config",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Data: message.EnrollResponseData{
|
||||
HostID: "foobar",
|
||||
Counter: 1,
|
||||
Config: cfg,
|
||||
TrustedKeys: marshalCAPublicKey(ca.Details.Curve, ca.Details.PublicKey),
|
||||
Organization: message.EnrollResponseDataOrg{
|
||||
ID: "foobaz",
|
||||
Name: "foobar's foo org",
|
||||
},
|
||||
Network: message.EnrollResponseDataNetwork{
|
||||
ID: "qux",
|
||||
Curve: message.NetworkCurve25519,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
config, pkey, creds, _, err := c.Enroll(ctx, testutil.NewTestLogger(), "foobar")
|
||||
require.NoError(t, err)
|
||||
|
||||
// make sure all credential values were set
|
||||
assert.NotEmpty(t, creds.HostID)
|
||||
assert.NotEmpty(t, creds.PrivateKey)
|
||||
assert.NotEmpty(t, creds.TrustedKeys)
|
||||
assert.NotEmpty(t, creds.Counter)
|
||||
|
||||
// make sure we got a config back
|
||||
assert.NotEmpty(t, config)
|
||||
assert.NotEmpty(t, pkey)
|
||||
|
||||
// Buffer that will store the logs sent to the service for verification
|
||||
var buf bytes.Buffer
|
||||
|
||||
// This time sign the response with the correct CA key.
|
||||
ts.ExpectStreamingRequest(message.CommandResponse, http.StatusOK, func(r message.RequestWrapper) []byte {
|
||||
return jsonMarshal(struct{}{})
|
||||
})
|
||||
|
||||
sc, err := c.StreamCommandResponse(context.Background(), *creds, "responseToken")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Configure a logger to write to a buffer and the stream
|
||||
logger := logrus.New()
|
||||
logger.SetFormatter(&logrus.JSONFormatter{})
|
||||
logger.SetOutput(io.MultiWriter(sc, &buf))
|
||||
logger.SetLevel(logrus.DebugLevel)
|
||||
|
||||
logger.Info("Hello, world! info!")
|
||||
logger.Warn("Hello, world! warning!")
|
||||
|
||||
err = sc.Close()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sc.Err())
|
||||
|
||||
require.Equal(t, buf.Bytes(), ts.LastStreamedBody())
|
||||
|
||||
// Test error handling
|
||||
errorMsg := "sample error"
|
||||
ts.ExpectStreamingRequest(message.CommandResponse, http.StatusBadRequest, func(r message.RequestWrapper) []byte {
|
||||
return jsonMarshal(message.EnrollResponse{
|
||||
Errors: message.APIErrors{{
|
||||
Code: "ERR_INVALID_VALUE",
|
||||
Message: errorMsg,
|
||||
}},
|
||||
})
|
||||
})
|
||||
|
||||
buf.Reset()
|
||||
|
||||
sc, err = c.StreamCommandResponse(context.Background(), *creds, "responseToken")
|
||||
require.NoError(t, err)
|
||||
|
||||
logger.SetOutput(io.MultiWriter(sc, &buf))
|
||||
|
||||
logger.Info("Hello, world! info!")
|
||||
logger.Warn("Hello, world! warning!")
|
||||
|
||||
// Close shouldn't return an error - that's only if the writer fails to close
|
||||
assert.NoError(t, sc.Close())
|
||||
// Err should return the error from the server
|
||||
assert.Error(t, sc.Err())
|
||||
|
||||
assert.Empty(t, ts.Errors())
|
||||
assert.Equal(t, 0, ts.RequestsRemaining(), ts.ExpectedRequests())
|
||||
}
|
||||
|
||||
func jsonMarshal(v interface{}) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestTimeout(t *testing.T) {
|
||||
ts := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
fmt.Fprintln(w, "OK")
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
useragent := "TestTimeout agent"
|
||||
c := NewClient(useragent, ts.URL)
|
||||
// The default timeout is 1 minutes. Assert the default value.
|
||||
assert.Equal(t, c.client.Timeout, 2*time.Minute)
|
||||
// The default streaming timeout is 15 minutes. Assert the default value.
|
||||
assert.Equal(t, c.streamingClient.Timeout, 15*time.Minute)
|
||||
// Overwrite the default value with a 10 millisecond timeout for test brevity.
|
||||
c.client.Timeout = 10 * time.Millisecond
|
||||
// DO IT
|
||||
_, err := c.client.Get(ts.URL + "/lol")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestOverrideTimeout(t *testing.T) {
|
||||
ts := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
fmt.Fprintln(w, "OK")
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
useragent := "TestTimeout agent"
|
||||
c := NewClient(useragent, ts.URL)
|
||||
// DO IT
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||
defer cancel()
|
||||
_, _, _, _, err := c.Enroll(ctx, testutil.NewTestLogger(), "ABC123")
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
func marshalCAPublicKey(curve cert.Curve, pubkey []byte) []byte {
|
||||
switch curve {
|
||||
case cert.Curve_CURVE25519:
|
||||
return pem.EncodeToMemory(&pem.Block{Type: keys.CmeshEd25519PublicKeyBanner, Bytes: pubkey})
|
||||
case cert.Curve_P256:
|
||||
return pem.EncodeToMemory(&pem.Block{Type: keys.CmeshECDSAP256PublicKeyBanner, Bytes: pubkey})
|
||||
default:
|
||||
panic("unsupported curve")
|
||||
}
|
||||
}
|
493
cmapitest/cmapitest.go
Normal file
493
cmapitest/cmapitest.go
Normal file
|
@ -0,0 +1,493 @@
|
|||
// Package cmapitest contains utilities for testing the cmapi package. Be aware
|
||||
// that any function in this package may panic on error.
|
||||
package cmapitest
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"git.ct129.com/VPN/cmapi/keys"
|
||||
"git.ct129.com/VPN/cmapi/message"
|
||||
"git.ct129.com/VPN/cmesh/cert"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// m is a helper type for building out generic maps (e.g. for marshalling.)
|
||||
type m map[string]interface{}
|
||||
|
||||
type Server struct {
|
||||
*httptest.Server
|
||||
|
||||
errors []error
|
||||
|
||||
streamedBody []byte
|
||||
|
||||
expectedRequests []requestResponse
|
||||
|
||||
expectedEdPubkey ed25519.PublicKey
|
||||
expectedP256Pubkey *ecdsa.PublicKey
|
||||
|
||||
expectedUserAgent string
|
||||
|
||||
// curve is set by the enroll request (which must match expectedEnrollment)
|
||||
curve message.NetworkCurve
|
||||
}
|
||||
|
||||
func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("User-Agent") != s.expectedUserAgent {
|
||||
s.errors = append(
|
||||
s.errors,
|
||||
fmt.Errorf("unexpected user agent: %s, expected: %s", r.Header.Get("User-Agent"), s.expectedUserAgent),
|
||||
)
|
||||
}
|
||||
|
||||
// There are no more test cases to return, so write nothing and return
|
||||
if s.RequestsRemaining() == 0 {
|
||||
s.errors = append(s.errors, fmt.Errorf("unexpected request - no mock responses to return"))
|
||||
http.Error(w, "unexpected request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.URL.Path {
|
||||
case message.EnrollEndpoint:
|
||||
w.Header().Set("X-Request-ID", "SupaDoopaRequestIdentifier")
|
||||
s.handlerEnroll(w, r)
|
||||
case message.EndpointV1:
|
||||
s.handlerDNClient(w, r)
|
||||
default:
|
||||
s.errors = append(s.errors, fmt.Errorf("invalid request path %s", r.URL.Path))
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlerEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the test case to validate
|
||||
expected := s.expectedRequests[0]
|
||||
s.expectedRequests = s.expectedRequests[1:]
|
||||
if expected.dnclientAPI {
|
||||
s.errors = append(s.errors, fmt.Errorf("unexpected enrollment request - expected dnclient API request"))
|
||||
http.Error(w, "unexpected enrollment request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
res := expected.enrollRequestResponse
|
||||
|
||||
// read and unmarshal body
|
||||
var req message.EnrollRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.errors = append(s.errors, fmt.Errorf("failed to decode enroll request: %w", err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// soft failure, we can continue
|
||||
if req.Timestamp.IsZero() {
|
||||
s.errors = append(s.errors, fmt.Errorf("missing timestamp"))
|
||||
}
|
||||
|
||||
if res.curve == message.NetworkCurve25519 {
|
||||
if err := s.SetEdPubkey(req.HostPubkeyEd25519); err != nil {
|
||||
s.errors = append(s.errors, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if res.curve == message.NetworkCurveP256 {
|
||||
if err := s.SetP256Pubkey(req.HostPubkeyP256); err != nil {
|
||||
s.errors = append(s.errors, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.curve = res.curve
|
||||
|
||||
w.Write(res.response(req))
|
||||
}
|
||||
|
||||
func (s *Server) SetCurve(curve message.NetworkCurve) {
|
||||
s.curve = curve
|
||||
}
|
||||
|
||||
func (s *Server) SetEdPubkey(edPubkeyPEM []byte) error {
|
||||
// hard failure, return
|
||||
edPubkey, rest, err := keys.UnmarshalHostEd25519PublicKey(edPubkeyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal ed pubkey: %w", err)
|
||||
}
|
||||
s.expectedEdPubkey = edPubkey
|
||||
|
||||
// soft failure, log it and avoid bailing the request
|
||||
if len(rest) > 0 {
|
||||
s.errors = append(s.errors, fmt.Errorf("unexpected trailer in ed pubkey: %s", rest))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) SetP256Pubkey(p256PubkeyPEM []byte) error {
|
||||
// hard failure, return
|
||||
pubkey, rest, err := keys.UnmarshalHostP256PublicKey(p256PubkeyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal P256 pubkey: %w", err)
|
||||
}
|
||||
s.expectedP256Pubkey = pubkey
|
||||
|
||||
// soft failure, log it and avoid bailing the request
|
||||
if len(rest) > 0 {
|
||||
s.errors = append(s.errors, fmt.Errorf("unexpected trailer in ed pubkey: %s", rest))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handlerDNClient(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the test case to validate
|
||||
expected := s.expectedRequests[0]
|
||||
s.expectedRequests = s.expectedRequests[1:]
|
||||
if !expected.dnclientAPI {
|
||||
s.errors = append(s.errors, fmt.Errorf("unexpected dnclient API request - expected enrollment request"))
|
||||
http.Error(w, "unexpected dnclient API request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
res := expected.dncRequestResponse
|
||||
|
||||
jd := json.NewDecoder(r.Body)
|
||||
|
||||
req := message.RequestV1{}
|
||||
err := jd.Decode(&req)
|
||||
if err != nil {
|
||||
s.errors = append(s.errors, fmt.Errorf("failed to decode request: %w", err))
|
||||
http.Error(w, "failed to decode request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Assert that the signature is correct
|
||||
switch s.curve {
|
||||
case message.NetworkCurve25519:
|
||||
if !ed25519.Verify(s.expectedEdPubkey, []byte(req.Message), req.Signature) {
|
||||
s.errors = append(s.errors, fmt.Errorf("invalid signature"))
|
||||
http.Error(w, "invalid signature", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
case message.NetworkCurveP256:
|
||||
// Convert the signature to a format Go understands
|
||||
var esig struct {
|
||||
R, S *big.Int
|
||||
}
|
||||
if _, err := asn1.Unmarshal(req.Signature, &esig); err != nil {
|
||||
s.errors = append(s.errors, fmt.Errorf("failed to unmarshal signature: %w", err))
|
||||
http.Error(w, "failed to unmarshal signature", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
hashed := sha256.Sum256([]byte(req.Message))
|
||||
if !ecdsa.Verify(s.expectedP256Pubkey, hashed[:], esig.R, esig.S) {
|
||||
s.errors = append(s.errors, fmt.Errorf("invalid signature"))
|
||||
http.Error(w, "invalid signature", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
default:
|
||||
s.errors = append(s.errors, fmt.Errorf("invalid curve"))
|
||||
http.Error(w, "invalid curve", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode the signed message
|
||||
decodedMsg, err := base64.StdEncoding.DecodeString(req.Message)
|
||||
if err != nil {
|
||||
s.errors = append(s.errors, fmt.Errorf("failed to decode request message: %w", err))
|
||||
http.Error(w, "failed to decode request message", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
msg := message.RequestWrapper{}
|
||||
err = json.Unmarshal(decodedMsg, &msg)
|
||||
if err != nil {
|
||||
s.errors = append(s.errors, fmt.Errorf("failed to unmarshal request wrapper: %w", err))
|
||||
http.Error(w, "failed to unmarshal request request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Require the expected request type, otherwise we have derailed.
|
||||
if msg.Type != res.expectedType {
|
||||
s.errors = append(s.errors, fmt.Errorf("%s is not expected message type %s", msg.Type, res.expectedType))
|
||||
http.Error(w, fmt.Sprintf("unexpected message type %s, wanted %s", msg.Type, res.expectedType), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case message.DoUpdate:
|
||||
var updateKeys message.DoUpdateRequest
|
||||
err = json.Unmarshal(msg.Value, &updateKeys)
|
||||
if err != nil {
|
||||
s.errors = append(s.errors, fmt.Errorf("failed to unmarshal DoUpdateRequest: %w", err))
|
||||
http.Error(w, "failed to unmarshal DoUpdateRequest", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch s.curve {
|
||||
case message.NetworkCurve25519:
|
||||
if err := s.SetEdPubkey(updateKeys.HostPubkeyEd25519); err != nil {
|
||||
s.errors = append(s.errors, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case message.NetworkCurveP256:
|
||||
if err := s.SetP256Pubkey(updateKeys.HostPubkeyP256); err != nil {
|
||||
s.errors = append(s.errors, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
default:
|
||||
s.errors = append(s.errors, fmt.Errorf("invalid curve"))
|
||||
http.Error(w, "invalid curve", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
case message.LongPollWait:
|
||||
var longPoll message.LongPollWaitRequest
|
||||
err = json.Unmarshal(msg.Value, &longPoll)
|
||||
if err != nil {
|
||||
s.errors = append(s.errors, fmt.Errorf("failed to unmarshal LongPollWaitRequest: %w", err))
|
||||
http.Error(w, "failed to unmarshal LongPollWaitRequest", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(longPoll.SupportedActions) == 0 {
|
||||
s.errors = append(s.errors, fmt.Errorf("no supported actions"))
|
||||
http.Error(w, "no supported actions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
case message.CommandResponse:
|
||||
var cmdResponse message.CommandResponseRequest
|
||||
err = json.Unmarshal(msg.Value, &cmdResponse)
|
||||
if err != nil {
|
||||
s.errors = append(s.errors, fmt.Errorf("failed to unmarshal StreamLogsRequest: %w", err))
|
||||
http.Error(w, "failed to unmarshal CommandResponse", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if expected.isStreamingRequest {
|
||||
s.streamedBody, err = io.ReadAll(io.MultiReader(jd.Buffered(), r.Body))
|
||||
if err != nil {
|
||||
s.errors = append(s.errors, fmt.Errorf("failed to read body: %w", err))
|
||||
http.Error(w, "failed to read body", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// return the associated response
|
||||
w.WriteHeader(res.statusCode)
|
||||
w.Write(res.response(msg))
|
||||
}
|
||||
|
||||
func (s *Server) ExpectEnrollment(code string, curve message.NetworkCurve, response func(req message.EnrollRequest) []byte) {
|
||||
s.expectedRequests = append(s.expectedRequests, requestResponse{
|
||||
dnclientAPI: false,
|
||||
enrollRequestResponse: enrollRequestResponse{
|
||||
expectedCode: code,
|
||||
response: response,
|
||||
curve: curve,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) ExpectRequest(msgType string, statusCode int, response func(r message.RequestWrapper) []byte) {
|
||||
s.expectedRequests = append(s.expectedRequests, requestResponse{
|
||||
dnclientAPI: true,
|
||||
dncRequestResponse: dncRequestResponse{
|
||||
statusCode: statusCode,
|
||||
expectedType: msgType,
|
||||
response: response,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) ExpectStreamingRequest(msgType string, statusCode int, response func(r message.RequestWrapper) []byte) {
|
||||
s.expectedRequests = append(s.expectedRequests, requestResponse{
|
||||
dnclientAPI: true,
|
||||
isStreamingRequest: true,
|
||||
dncRequestResponse: dncRequestResponse{
|
||||
statusCode: statusCode,
|
||||
expectedType: msgType,
|
||||
response: response,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) Errors() []error {
|
||||
defer func() {
|
||||
s.errors = []error{}
|
||||
}()
|
||||
|
||||
return s.errors
|
||||
}
|
||||
|
||||
func (s *Server) RequestsRemaining() int {
|
||||
return len(s.expectedRequests)
|
||||
}
|
||||
|
||||
func (s *Server) ExpectedRequests() []requestResponse {
|
||||
return s.expectedRequests
|
||||
}
|
||||
|
||||
func (s *Server) LastStreamedBody() []byte {
|
||||
return s.streamedBody
|
||||
}
|
||||
|
||||
func NewServer(expectedUserAgent string) *Server {
|
||||
s := &Server{
|
||||
errors: []error{},
|
||||
expectedRequests: []requestResponse{},
|
||||
expectedUserAgent: expectedUserAgent,
|
||||
curve: message.NetworkCurve25519, // default for legacy tests
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(s.handler))
|
||||
s.Server = ts
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
type requestResponse struct {
|
||||
dnclientAPI bool
|
||||
dncRequestResponse dncRequestResponse
|
||||
enrollRequestResponse enrollRequestResponse
|
||||
isStreamingRequest bool
|
||||
}
|
||||
|
||||
type enrollRequestResponse struct {
|
||||
expectedCode string
|
||||
curve message.NetworkCurve
|
||||
|
||||
response func(r message.EnrollRequest) []byte
|
||||
}
|
||||
|
||||
type dncRequestResponse struct {
|
||||
expectedType string
|
||||
|
||||
statusCode int
|
||||
response func(r message.RequestWrapper) []byte
|
||||
}
|
||||
|
||||
func GetNonce(r message.RequestWrapper) []byte {
|
||||
msg := struct{ Nonce []byte }{}
|
||||
if err := json.Unmarshal(r.Value, &msg); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return msg.Nonce
|
||||
}
|
||||
|
||||
// CmeshCfg returns a dummy Cmesh config file, returning the marshalled cert in yaml format.
|
||||
func CmeshCfg(caCert []byte) []byte {
|
||||
rawConfig := m{
|
||||
"pki": m{
|
||||
"ca": string(caCert), // []byte will convert to a YAML list
|
||||
"cert": string(caCert), // []byte will convert to a YAML list
|
||||
// key will be filled in on the host
|
||||
},
|
||||
"static_host_map": map[string][]string{},
|
||||
"punchy": m{
|
||||
"punch": true,
|
||||
"respond": true,
|
||||
},
|
||||
"lighthouse": "",
|
||||
"listen": "",
|
||||
"tun": m{
|
||||
"dev": "cmesh99", // 99 chosen to try to avoid conflicts
|
||||
"mtu": 1300,
|
||||
"drop_local_broadcast": true,
|
||||
"drop_multicast": true,
|
||||
},
|
||||
"logging": m{
|
||||
"level": "info",
|
||||
"format": "text",
|
||||
},
|
||||
"firewall": m{
|
||||
"outbound": "",
|
||||
"inbound": "",
|
||||
},
|
||||
}
|
||||
cmeshCfg, err := yaml.Marshal(rawConfig)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return cmeshCfg
|
||||
}
|
||||
|
||||
func CmeshCACert() (*cert.CmeshCertificate, ed25519.PrivateKey) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
nc := &cert.CmeshCertificate{
|
||||
Details: cert.CmeshCertificateDetails{
|
||||
Name: "UnitTesting",
|
||||
Groups: []string{"testa", "testb"},
|
||||
Ips: []*net.IPNet{},
|
||||
Subnets: []*net.IPNet{},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
PublicKey: pub,
|
||||
IsCA: true,
|
||||
},
|
||||
}
|
||||
err = nc.Sign(nc.Details.Curve, priv)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return nc, priv
|
||||
}
|
||||
|
||||
func CmeshCACertP256() (*cert.CmeshCertificate, *ecdsa.PrivateKey) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// ecdh.PrivateKey lets us get at the encoded bytes, even though
|
||||
// we aren't using ECDH here.
|
||||
eKey, err := key.ECDH()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
rawPriv := eKey.Bytes()
|
||||
pub := eKey.PublicKey().Bytes()
|
||||
|
||||
nc := &cert.CmeshCertificate{
|
||||
Details: cert.CmeshCertificateDetails{
|
||||
Curve: cert.Curve_P256,
|
||||
Name: "UnitTesting",
|
||||
Groups: []string{"testa", "testb"},
|
||||
Ips: []*net.IPNet{},
|
||||
Subnets: []*net.IPNet{},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
PublicKey: pub,
|
||||
IsCA: true,
|
||||
},
|
||||
}
|
||||
err = nc.Sign(nc.Details.Curve, rawPriv)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return nc, key
|
||||
}
|
1
examples/simple/.gitignore
vendored
Normal file
1
examples/simple/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
simple
|
82
examples/simple/main.go
Normal file
82
examples/simple/main.go
Normal file
|
@ -0,0 +1,82 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.ct129.com/VPN/cmapi"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server := flag.String("server", "https://cmesh.ct129.com", "API server (e.g. https://cmesh.ct129.com)")
|
||||
code := flag.String("code", "", "enrollment code")
|
||||
flag.Parse()
|
||||
|
||||
if *code == "" {
|
||||
fmt.Println("-code flag must be set")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger := logrus.New()
|
||||
c := cmapi.NewClient("api-example/1.0", *server)
|
||||
|
||||
// initial enrollment example
|
||||
config, pkey, creds, meta, err := c.Enroll(context.Background(), logger, *code)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to enroll")
|
||||
}
|
||||
|
||||
config, err = cmapi.InsertConfigPrivateKey(config, pkey)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to insert private key into config")
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"Host ID: %s (Org: %s, ID: %s), Counter: %d, Config:\n\n%s\n",
|
||||
creds.HostID,
|
||||
meta.OrganizationName,
|
||||
meta.OrganizationID,
|
||||
creds.Counter,
|
||||
config,
|
||||
)
|
||||
|
||||
// loop and check for updates example
|
||||
for {
|
||||
logger.Info("Waiting 60 seconds to check for update")
|
||||
time.Sleep(60 * time.Second)
|
||||
|
||||
// check for an update and perform the update if available
|
||||
updateAvailable, err := c.CheckForUpdate(context.Background(), *creds)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to check for update")
|
||||
continue
|
||||
}
|
||||
|
||||
if updateAvailable {
|
||||
// be careful not to blow away creds in case err != nil
|
||||
// another option is to pass credentials by reference and let DoUpdate modify the struct if successful but
|
||||
// this makes it less obvious to the caller that they need to save the new credentials to disk
|
||||
config, pkey, newCreds, err := c.DoUpdate(context.Background(), *creds)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to perform update")
|
||||
continue
|
||||
}
|
||||
|
||||
config, err = cmapi.InsertConfigPrivateKey(config, pkey)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Failed to insert private key into config")
|
||||
}
|
||||
|
||||
creds = newCreds
|
||||
|
||||
fmt.Printf("Counter: %d, config:\n\n%s\n", creds.Counter, config)
|
||||
|
||||
// XXX Now would be a good time to save both the new config and credentials to disk and reload Cmesh.
|
||||
}
|
||||
}
|
||||
}
|
22
go.mod
Normal file
22
go.mod
Normal file
|
@ -0,0 +1,22 @@
|
|||
module git.ct129.com/VPN/cmapi
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/sirupsen/logrus v1.9.2
|
||||
git.ct129.com/VPN/cmesh v1.7.1
|
||||
github.com/stretchr/testify v1.8.2
|
||||
golang.org/x/crypto v0.9.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
52
go.sum
Normal file
52
go.sum
Normal file
|
@ -0,0 +1,52 @@
|
|||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
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.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y=
|
||||
github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
git.ct129.com/VPN/cmesh v1.7.1 h1:+kzPkx9rMXJKj43N7Zcdb+ZsHAX+/u2beS7qPHbWhdw=
|
||||
git.ct129.com/VPN/cmesh v1.7.1/go.mod h1:cnaoahkUipDs1vrNoIszyp0QPRIQN9Pm68ppQEW1Fhg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
37
internal/testutil/testutil.go
Normal file
37
internal/testutil/testutil.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
package testutil
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// NewTestLogger returns a *logrus.Logger struct configured for testing (e.g. end-to-end tests, unit tests, etc.)
|
||||
func NewTestLogger() *logrus.Logger {
|
||||
l := logrus.New()
|
||||
l.SetFormatter(&logrus.JSONFormatter{
|
||||
DisableTimestamp: true,
|
||||
FieldMap: logrus.FieldMap{
|
||||
logrus.FieldKeyMsg: "message",
|
||||
},
|
||||
})
|
||||
|
||||
v := os.Getenv("TEST_LOGS")
|
||||
if v == "" {
|
||||
l.SetOutput(ioutil.Discard)
|
||||
return l
|
||||
}
|
||||
|
||||
switch v {
|
||||
case "1":
|
||||
// This is the default level but we are being explicit
|
||||
l.SetLevel(logrus.InfoLevel)
|
||||
case "2":
|
||||
l.SetLevel(logrus.DebugLevel)
|
||||
case "3":
|
||||
l.SetLevel(logrus.TraceLevel)
|
||||
}
|
||||
|
||||
return l
|
||||
}
|
9
keys/credentials.go
Normal file
9
keys/credentials.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package keys
|
||||
|
||||
// Credentials contains information necessary to make requests against the DNClient API.
|
||||
type Credentials struct {
|
||||
HostID string
|
||||
PrivateKey PrivateKey
|
||||
Counter uint
|
||||
TrustedKeys []TrustedKey
|
||||
}
|
180
keys/crypto.go
Normal file
180
keys/crypto.go
Normal file
|
@ -0,0 +1,180 @@
|
|||
package keys
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"git.ct129.com/VPN/cmesh/cert"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
// Ed25519PublicKey is a wrapper around an Ed25519 public key that implements
|
||||
// the PublicKey interface.
|
||||
type Ed25519PublicKey struct {
|
||||
ed25519.PublicKey
|
||||
}
|
||||
|
||||
func (k Ed25519PublicKey) Unwrap() interface{} {
|
||||
return k.PublicKey
|
||||
}
|
||||
|
||||
func (k Ed25519PublicKey) MarshalPEM() ([]byte, error) {
|
||||
return MarshalHostEd25519PublicKey(k.PublicKey)
|
||||
}
|
||||
|
||||
// P256PublicKey is a wrapper around an ECDSA public key that implements the
|
||||
// PublicKey interface.
|
||||
type P256PublicKey struct {
|
||||
*ecdsa.PublicKey
|
||||
}
|
||||
|
||||
func (k P256PublicKey) Unwrap() interface{} {
|
||||
return k.PublicKey
|
||||
}
|
||||
|
||||
func (k P256PublicKey) MarshalPEM() ([]byte, error) {
|
||||
return MarshalHostP256PublicKey(k.PublicKey)
|
||||
}
|
||||
|
||||
// Ed25519PrivateKey is a wrapper around an Ed25519 private key that implements
|
||||
// the PrivateKey interface.
|
||||
type Ed25519PrivateKey struct {
|
||||
ed25519.PrivateKey
|
||||
}
|
||||
|
||||
func (k Ed25519PrivateKey) Sign(msg []byte) ([]byte, error) {
|
||||
return ed25519.Sign(k.PrivateKey, msg), nil
|
||||
}
|
||||
|
||||
func (k Ed25519PrivateKey) Unwrap() interface{} {
|
||||
return k.PrivateKey
|
||||
}
|
||||
|
||||
func (k Ed25519PrivateKey) MarshalPEM() ([]byte, error) {
|
||||
return MarshalHostEd25519PrivateKey(k.PrivateKey)
|
||||
}
|
||||
|
||||
func (k Ed25519PrivateKey) Public() PublicKey {
|
||||
return Ed25519PublicKey{k.PrivateKey.Public().(ed25519.PublicKey)}
|
||||
}
|
||||
|
||||
// P256PrivateKey is a wrapper around an ECDSA private key that implements the
|
||||
// PrivateKey interface.
|
||||
type P256PrivateKey struct {
|
||||
*ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
func (k P256PrivateKey) Sign(msg []byte) ([]byte, error) {
|
||||
hashed := sha256.Sum256(msg)
|
||||
return ecdsa.SignASN1(rand.Reader, k.PrivateKey, hashed[:])
|
||||
}
|
||||
|
||||
func (k P256PrivateKey) Unwrap() interface{} {
|
||||
return k.PrivateKey
|
||||
}
|
||||
|
||||
func (k P256PrivateKey) MarshalPEM() ([]byte, error) {
|
||||
return MarshalHostP256PrivateKey(k.PrivateKey)
|
||||
}
|
||||
|
||||
func (k P256PrivateKey) Public() PublicKey {
|
||||
return P256PublicKey{k.PrivateKey.Public().(*ecdsa.PublicKey)}
|
||||
}
|
||||
|
||||
// newKeys25519 returns a new set of Cmesh (Diffie-Hellman) keys and a new set of Ed25519 (request signing) keys.
|
||||
func newKeys25519() ([]byte, []byte, ed25519.PublicKey, ed25519.PrivateKey, error) {
|
||||
dhPubkeyPEM, dhPrivkeyPEM, err := newCmeshX25519KeypairPEM()
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to generate Cmesh keypair: %s", err)
|
||||
}
|
||||
|
||||
edPubkey, edPrivkey, err := newEd25519Keypair()
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to generate Ed25519 keypair: %s", err)
|
||||
}
|
||||
|
||||
return dhPubkeyPEM, dhPrivkeyPEM, edPubkey, edPrivkey, nil
|
||||
}
|
||||
|
||||
// newKeysP256 returns a new set of Cmesh (Diffie-Hellman) ECDH P256 keys and a new set of ECDSA (request signing) keys.
|
||||
func newKeysP256() ([]byte, []byte, *ecdsa.PublicKey, *ecdsa.PrivateKey, error) {
|
||||
ecdhPubkeyPEM, ecdhPrivkeyPEM, err := newCmeshP256KeypairPEM()
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to generate Cmesh keypair: %s", err)
|
||||
}
|
||||
|
||||
ecdsaPubkey, ecdsaPrivkey, err := newP256Keypair()
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to generate Ed25519 keypair: %s", err)
|
||||
}
|
||||
|
||||
return ecdhPubkeyPEM, ecdhPrivkeyPEM, ecdsaPubkey, ecdsaPrivkey, nil
|
||||
}
|
||||
|
||||
// newX25519Keypair create a pair of X25519 public key and private key and returns them, in that order.
|
||||
func newX25519Keypair() ([]byte, []byte, error) {
|
||||
var privkey = make([]byte, curve25519.ScalarSize)
|
||||
if _, err := io.ReadFull(rand.Reader, privkey); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pubkey, err := curve25519.X25519(privkey, curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return pubkey, privkey, nil
|
||||
}
|
||||
|
||||
// newEd25519Keypair returns a new Ed25519 (pubkey, privkey) pair usable for signing.
|
||||
func newEd25519Keypair() (ed25519.PublicKey, ed25519.PrivateKey, error) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pub, priv, nil
|
||||
}
|
||||
|
||||
// newP256Keypair create a pair of P256 public key and private key and returns them, in that order.
|
||||
func newP256Keypair() (*ecdsa.PublicKey, *ecdsa.PrivateKey, error) {
|
||||
privkey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("error while generating ecdsa keys: %s", err)
|
||||
}
|
||||
|
||||
return privkey.Public().(*ecdsa.PublicKey), privkey, nil
|
||||
}
|
||||
|
||||
// newCmeshX25519KeypairPEM returns a new Cmesh keypair (X25519) in PEM format.
|
||||
func newCmeshX25519KeypairPEM() ([]byte, []byte, error) {
|
||||
pubkey, privkey, err := newX25519Keypair()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pubkey, privkey = cert.MarshalX25519PublicKey(pubkey), cert.MarshalX25519PrivateKey(privkey)
|
||||
|
||||
return pubkey, privkey, nil
|
||||
}
|
||||
|
||||
// newCmeshP256KeypairPEM returns a new Cmesh keypair (P256) in PEM format.
|
||||
func newCmeshP256KeypairPEM() ([]byte, []byte, error) {
|
||||
rawPrivkey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("error while generating ecdsa keys: %s", err)
|
||||
}
|
||||
|
||||
ecdhPrivkey, err := rawPrivkey.ECDH()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pubkey := cert.MarshalPublicKey(cert.Curve_P256, ecdhPrivkey.PublicKey().Bytes())
|
||||
privkey := cert.MarshalPrivateKey(cert.Curve_P256, ecdhPrivkey.Bytes())
|
||||
|
||||
return pubkey, privkey, nil
|
||||
}
|
119
keys/keys.go
Normal file
119
keys/keys.go
Normal file
|
@ -0,0 +1,119 @@
|
|||
package keys
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Keys contains a set of P256 and X25519/Ed25519 keys. Only one set is used,
|
||||
// depending on the network the host is enrolled in. At the time of enrollment
|
||||
// clients do not know which curve the network uses, so both keys must be
|
||||
// generated.
|
||||
//
|
||||
// Cmesh keys are returned in PEM format as the public keys is sent off to the
|
||||
// DN API and the private Cmesh key is written to disk and parsed by the
|
||||
// Cmesh library. The host keys will be used to sign requests.
|
||||
type Keys struct {
|
||||
// 25519 Curve
|
||||
CmeshX25519PublicKeyPEM []byte // ECDH (Cmesh)
|
||||
CmeshX25519PrivateKeyPEM []byte // ECDH (Cmesh)
|
||||
HostEd25519PublicKey PublicKey // EdDSA (DN API)
|
||||
HostEd25519PrivateKey PrivateKey // EdDSA (DN API)
|
||||
|
||||
// P256 Curve
|
||||
CmeshP256PublicKeyPEM []byte // ECDH (Cmesh)
|
||||
CmeshP256PrivateKeyPEM []byte // ECDH (Cmesh)
|
||||
HostP256PublicKey PublicKey // ECDSA (DN API)
|
||||
HostP256PrivateKey PrivateKey // ECDSA (DN API)
|
||||
}
|
||||
|
||||
func New() (*Keys, error) {
|
||||
x25519PublicKeyPEM, x25519PrivateKeyPEM, ed25519PublicKey, ed25519PrivateKey, err := newKeys25519()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ed25519PublicKeyI, err := NewPublicKey(ed25519PublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ed25519PrivateKeyI, err := NewPrivateKey(ed25519PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ecdhP256PublicKeyPEM, ecdhP256PrivateKeyPEM, ecdsaP256PublicKey, ecdsaP256PrivateKey, err := newKeysP256()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ecdsaP256PublicKeyI, err := NewPublicKey(ecdsaP256PublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ecdsaP256PrivateKeyI, err := NewPrivateKey(ecdsaP256PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Keys{
|
||||
CmeshX25519PublicKeyPEM: x25519PublicKeyPEM,
|
||||
CmeshX25519PrivateKeyPEM: x25519PrivateKeyPEM,
|
||||
HostEd25519PublicKey: ed25519PublicKeyI,
|
||||
HostEd25519PrivateKey: ed25519PrivateKeyI,
|
||||
CmeshP256PublicKeyPEM: ecdhP256PublicKeyPEM,
|
||||
CmeshP256PrivateKeyPEM: ecdhP256PrivateKeyPEM,
|
||||
HostP256PublicKey: ecdsaP256PublicKeyI,
|
||||
HostP256PrivateKey: ecdsaP256PrivateKeyI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublicKey is a wrapper around public keys.
|
||||
type PublicKey interface {
|
||||
// Unwrap returns the internal public key object (e.g. *ecdsa.PublicKey or ed25519.PublicKey.)
|
||||
Unwrap() interface{}
|
||||
|
||||
// MarshalPEM returns the public key in PEM format.
|
||||
MarshalPEM() ([]byte, error)
|
||||
}
|
||||
|
||||
func NewPublicKey(k any) (PublicKey, error) {
|
||||
switch k := k.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
return P256PublicKey{k}, nil
|
||||
case ed25519.PublicKey:
|
||||
return Ed25519PublicKey{k}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported public key type: %T", k)
|
||||
}
|
||||
}
|
||||
|
||||
// PrivateKey is an interface used to generically sign messages regardless of
|
||||
// the network curve (P256/25519.)
|
||||
type PrivateKey interface {
|
||||
// Sign signs the message with the private key and returns the signature.
|
||||
Sign(msg []byte) ([]byte, error)
|
||||
|
||||
// Unwrap returns the internal private key object (e.g. *ecdsa.PrivateKey or ed25519.PrivateKey.)
|
||||
Unwrap() interface{}
|
||||
|
||||
// MarshalPEM returns the private key in PEM format.
|
||||
MarshalPEM() ([]byte, error)
|
||||
|
||||
// Public returns the public key associated with the private key.
|
||||
Public() PublicKey
|
||||
}
|
||||
|
||||
func NewPrivateKey(k any) (PrivateKey, error) {
|
||||
switch k := k.(type) {
|
||||
case *ecdsa.PrivateKey:
|
||||
return P256PrivateKey{k}, nil
|
||||
case ed25519.PrivateKey:
|
||||
return Ed25519PrivateKey{k}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported private key type: %T", k)
|
||||
}
|
||||
}
|
183
keys/pem.go
Normal file
183
keys/pem.go
Normal file
|
@ -0,0 +1,183 @@
|
|||
package keys
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const HostEd25519PublicKeyBanner = "CMESH HOST ED25519 PUBLIC KEY"
|
||||
const HostEd25519PrivateKeyBanner = "CMESH HOST ED25519 PRIVATE KEY"
|
||||
const HostP256PublicKeyBanner = "CMESH HOST P256 PUBLIC KEY"
|
||||
const HostP256PrivateKeyBanner = "CMESH HOST P256 PRIVATE KEY"
|
||||
|
||||
const CmeshECDSAP256PublicKeyBanner = "CMESH ECDSA P256 PUBLIC KEY"
|
||||
const CmeshEd25519PublicKeyBanner = "CMESH ED25519 PUBLIC KEY"
|
||||
|
||||
func MarshalHostEd25519PublicKey(k ed25519.PublicKey) ([]byte, error) {
|
||||
b, err := x509.MarshalPKIXPublicKey(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: HostEd25519PublicKeyBanner,
|
||||
Bytes: b,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func MarshalHostEd25519PrivateKey(k ed25519.PrivateKey) ([]byte, error) {
|
||||
b, err := x509.MarshalPKCS8PrivateKey(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: HostEd25519PrivateKeyBanner,
|
||||
Bytes: b,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func MarshalHostP256PublicKey(k *ecdsa.PublicKey) ([]byte, error) {
|
||||
b, err := x509.MarshalPKIXPublicKey(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: HostP256PublicKeyBanner,
|
||||
Bytes: b,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func MarshalHostP256PrivateKey(k *ecdsa.PrivateKey) ([]byte, error) {
|
||||
b, err := x509.MarshalECPrivateKey(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: HostP256PrivateKeyBanner,
|
||||
Bytes: b,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func UnmarshalHostEd25519PublicKey(b []byte) (ed25519.PublicKey, []byte, error) {
|
||||
k, r := pem.Decode(b)
|
||||
if k == nil {
|
||||
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
|
||||
}
|
||||
if k.Type != HostEd25519PublicKeyBanner {
|
||||
return nil, r, fmt.Errorf("bytes did not contain a proper DN Ed25519 public key banner")
|
||||
}
|
||||
|
||||
pkey, err := x509.ParsePKIXPublicKey(k.Bytes)
|
||||
if err != nil {
|
||||
return nil, r, fmt.Errorf("failed to parse public key: %s", err)
|
||||
}
|
||||
|
||||
return pkey.(ed25519.PublicKey), r, nil
|
||||
}
|
||||
|
||||
func UnmarshalHostEd25519PrivateKey(b []byte) (ed25519.PrivateKey, []byte, error) {
|
||||
k, r := pem.Decode(b)
|
||||
if k == nil {
|
||||
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
|
||||
}
|
||||
if k.Type != HostEd25519PrivateKeyBanner {
|
||||
return nil, r, fmt.Errorf("bytes did not contain a proper DN Ed25519 private key banner")
|
||||
}
|
||||
|
||||
pkey, err := x509.ParsePKCS8PrivateKey(k.Bytes)
|
||||
if err != nil {
|
||||
return nil, r, fmt.Errorf("failed to parse private key: %s", err)
|
||||
}
|
||||
|
||||
return pkey.(ed25519.PrivateKey), r, nil
|
||||
}
|
||||
|
||||
func UnmarshalHostP256PublicKey(b []byte) (*ecdsa.PublicKey, []byte, error) {
|
||||
k, r := pem.Decode(b)
|
||||
if k == nil {
|
||||
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
|
||||
}
|
||||
if k.Type != HostP256PublicKeyBanner {
|
||||
return nil, r, fmt.Errorf("bytes did not contain a proper DN P256 public key banner")
|
||||
}
|
||||
|
||||
pkey, err := x509.ParsePKIXPublicKey(k.Bytes)
|
||||
if err != nil {
|
||||
return nil, r, fmt.Errorf("failed to parse public key: %s", err)
|
||||
}
|
||||
|
||||
return pkey.(*ecdsa.PublicKey), r, nil
|
||||
}
|
||||
|
||||
func UnmarshalHostP256PrivateKey(b []byte) (*ecdsa.PrivateKey, []byte, error) {
|
||||
k, r := pem.Decode(b)
|
||||
if k == nil {
|
||||
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
|
||||
}
|
||||
if k.Type != HostP256PrivateKeyBanner {
|
||||
return nil, r, fmt.Errorf("bytes did not contain a proper DN P256 private key banner")
|
||||
}
|
||||
|
||||
pkey, err := x509.ParseECPrivateKey(k.Bytes)
|
||||
if err != nil {
|
||||
return nil, r, fmt.Errorf("failed to parse private key: %s", err)
|
||||
}
|
||||
|
||||
return pkey, r, nil
|
||||
}
|
||||
|
||||
func UnmarshalHostPrivateKey(b []byte) (PrivateKey, []byte, error) {
|
||||
k, r := pem.Decode(b)
|
||||
if k == nil {
|
||||
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
|
||||
}
|
||||
|
||||
switch k.Type {
|
||||
case HostP256PrivateKeyBanner:
|
||||
pkey, r, err := UnmarshalHostP256PrivateKey(b)
|
||||
if err != nil {
|
||||
return nil, r, err
|
||||
}
|
||||
pk, err := NewPrivateKey(pkey)
|
||||
return pk, r, err
|
||||
|
||||
case HostEd25519PrivateKeyBanner:
|
||||
pkey, r, err := UnmarshalHostEd25519PrivateKey(b)
|
||||
if err != nil {
|
||||
return nil, r, err
|
||||
}
|
||||
pk, err := NewPrivateKey(pkey)
|
||||
return pk, r, err
|
||||
|
||||
default:
|
||||
return nil, r, fmt.Errorf("input did not contain a valid private key banner")
|
||||
}
|
||||
}
|
||||
|
||||
func UnmarshalTrustedKey(b []byte) (TrustedKey, []byte, error) {
|
||||
k, r := pem.Decode(b)
|
||||
if k == nil {
|
||||
return nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
|
||||
}
|
||||
|
||||
switch k.Type {
|
||||
case CmeshECDSAP256PublicKeyBanner:
|
||||
if len(k.Bytes) != 65 {
|
||||
return nil, r, fmt.Errorf("key was not 65 bytes, is invalid P256 public key")
|
||||
}
|
||||
|
||||
x, y := elliptic.Unmarshal(elliptic.P256(), k.Bytes)
|
||||
return P256TrustedKey{&ecdsa.PublicKey{X: x, Y: y, Curve: elliptic.P256()}}, r, nil
|
||||
case CmeshEd25519PublicKeyBanner:
|
||||
if len(k.Bytes) != ed25519.PublicKeySize {
|
||||
return nil, r, fmt.Errorf("key was not 32 bytes, is invalid ed25519 public key")
|
||||
}
|
||||
|
||||
return Ed25519TrustedKey{ed25519.PublicKey(k.Bytes)}, r, nil
|
||||
default:
|
||||
return nil, r, fmt.Errorf("input did not contain a valid public key banner")
|
||||
}
|
||||
}
|
94
keys/trusted_keys.go
Normal file
94
keys/trusted_keys.go
Normal file
|
@ -0,0 +1,94 @@
|
|||
package keys
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/sha256"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// TrustedKey is an interface used to generically verify signatures returned
|
||||
// from the DN API regardless of whether the key is P256 or 25519.
|
||||
type TrustedKey interface {
|
||||
Verify(data []byte, sig []byte) bool
|
||||
Unwrap() any
|
||||
MarshalPEM() ([]byte, error)
|
||||
}
|
||||
|
||||
func NewTrustedKey(k any) (TrustedKey, error) {
|
||||
switch k := k.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
return P256TrustedKey{k}, nil
|
||||
case ed25519.PublicKey:
|
||||
return Ed25519TrustedKey{k}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported private key type: %T", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Ed25519TrustedKey is the Ed25519 implementation of TrustedKey.
|
||||
type Ed25519TrustedKey struct {
|
||||
ed25519.PublicKey
|
||||
}
|
||||
|
||||
func (key Ed25519TrustedKey) Verify(data []byte, sig []byte) bool {
|
||||
return ed25519.Verify(key.PublicKey, data, sig)
|
||||
}
|
||||
|
||||
func (key Ed25519TrustedKey) Unwrap() any {
|
||||
return key.PublicKey
|
||||
}
|
||||
|
||||
func (key Ed25519TrustedKey) MarshalPEM() ([]byte, error) {
|
||||
return pem.EncodeToMemory(&pem.Block{Type: CmeshEd25519PublicKeyBanner, Bytes: key.PublicKey}), nil
|
||||
}
|
||||
|
||||
// P256TrustedKey is the P256 implementation of TrustedKey.
|
||||
type P256TrustedKey struct {
|
||||
*ecdsa.PublicKey
|
||||
}
|
||||
|
||||
func (key P256TrustedKey) Verify(data []byte, sig []byte) bool {
|
||||
hash := sha256.Sum256(data)
|
||||
return ecdsa.VerifyASN1(key.PublicKey, hash[:], sig)
|
||||
}
|
||||
|
||||
func (key P256TrustedKey) Unwrap() any {
|
||||
return key.PublicKey
|
||||
}
|
||||
|
||||
func (key P256TrustedKey) MarshalPEM() ([]byte, error) {
|
||||
b := elliptic.Marshal(elliptic.P256(), key.X, key.Y)
|
||||
return pem.EncodeToMemory(&pem.Block{Type: CmeshECDSAP256PublicKeyBanner, Bytes: b}), nil
|
||||
}
|
||||
|
||||
// TrustedKeysToPEM converts a slice of TrustedKey to a PEM-encoded byte slice.
|
||||
func TrustedKeysToPEM(keys []TrustedKey) ([]byte, error) {
|
||||
result := []byte{}
|
||||
for _, key := range keys {
|
||||
pem, err := key.MarshalPEM()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, pem...)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TrustedKeysFromPEM converts a PEM-encoded byte slice to a slice of TrustedKey.
|
||||
func TrustedKeysFromPEM(pemKeys []byte) ([]TrustedKey, error) {
|
||||
keys := []TrustedKey{}
|
||||
for len(pemKeys) > 0 {
|
||||
var err error
|
||||
var pubkey TrustedKey
|
||||
pubkey, pemKeys, err = UnmarshalTrustedKey(pemKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, pubkey)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
207
message/message.go
Normal file
207
message/message.go
Normal file
|
@ -0,0 +1,207 @@
|
|||
package message
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DNClient API message types
|
||||
const (
|
||||
CheckForUpdate = "CheckForUpdate"
|
||||
DoUpdate = "DoUpdate"
|
||||
LongPollWait = "LongPollWait"
|
||||
CommandResponse = "CommandResponse"
|
||||
)
|
||||
|
||||
// EndpointV1 is the version 1 DNClient API endpoint.
|
||||
const EndpointV1 = "/v1/dnclient"
|
||||
|
||||
// RequestV1 is the version 1 DNClient request message.
|
||||
// Ver is always 1, HostID is the calling dnclient hostID.
|
||||
// Msg is a base64-encoded message, and Signature is an ed25519
|
||||
// signature over the message, which can be verified using the
|
||||
// host's previously enrolled ed25519 public key.
|
||||
type RequestV1 struct {
|
||||
Version int `json:"version"`
|
||||
HostID string `json:"hostID"`
|
||||
Counter uint `json:"counter"`
|
||||
Message string `json:"message"`
|
||||
Signature []byte `json:"signature"`
|
||||
}
|
||||
|
||||
// RequestWrapper wraps a DNClient request message. It consists of a
|
||||
// type and value, with the type string indicating how to interpret the value blob.
|
||||
type RequestWrapper struct {
|
||||
Type string `json:"type"`
|
||||
Value []byte `json:"value"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// SignedResponseWrapper contains a response message and a signature to validate inside "data."
|
||||
type SignedResponseWrapper struct {
|
||||
Data SignedResponse `json:"data"`
|
||||
}
|
||||
|
||||
// SignedResponse contains a response message and a signature to validate.
|
||||
type SignedResponse struct {
|
||||
Version int `json:"version"`
|
||||
Message []byte `json:"message"`
|
||||
Signature []byte `json:"signature"`
|
||||
}
|
||||
|
||||
// CheckForUpdateResponseWrapper contains a response to CheckForUpdate inside "data."
|
||||
type CheckForUpdateResponseWrapper struct {
|
||||
Data CheckForUpdateResponse `json:"data"`
|
||||
}
|
||||
|
||||
// CheckForUpdateResponse is the response generated for a CheckForUpdate request.
|
||||
type CheckForUpdateResponse struct {
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
}
|
||||
|
||||
// DoUpdateRequest is the request sent for a DoUpdate request.
|
||||
type DoUpdateRequest struct {
|
||||
HostPubkeyEd25519 []byte `json:"edPubkeyPEM"` // X25519 (used for key exchange)
|
||||
CmeshPubkeyX25519 []byte `json:"dhPubkeyPEM"` // Ed25519 (used for signing)
|
||||
HostPubkeyP256 []byte `json:"p256HostPubkeyPEM"` // P256 (used for signing)
|
||||
CmeshPubkeyP256 []byte `json:"p256CmeshPubkeyPEM"` // P256 (used for key exchange)
|
||||
Nonce []byte `json:"nonce"`
|
||||
}
|
||||
|
||||
// DoUpdateResponse is the response generated for a DoUpdate request.
|
||||
type DoUpdateResponse struct {
|
||||
Config []byte `json:"config"`
|
||||
Counter uint `json:"counter"`
|
||||
Nonce []byte `json:"nonce"`
|
||||
TrustedKeys []byte `json:"trustedKeys"`
|
||||
}
|
||||
|
||||
// LongPollWaitResponseWrapper contains a response to LongPollWait inside "data."
|
||||
type LongPollWaitResponseWrapper struct {
|
||||
Data LongPollWaitResponse `json:"data"`
|
||||
}
|
||||
|
||||
// LongPollWaitRequest is the request message associated with a LongPollWait call.
|
||||
type LongPollWaitRequest struct {
|
||||
SupportedActions []string `json:"supportedActions"`
|
||||
}
|
||||
|
||||
// LongPollWaitResponse is the response message associated with a LongPollWait call.
|
||||
type LongPollWaitResponse struct {
|
||||
Action json.RawMessage `json:"action"` // e.g. NoOp, StreamLogs, DoUpdate
|
||||
ResponseToken string `json:"responseToken"`
|
||||
}
|
||||
|
||||
// CommandResponseResponseWrapper contains a response to CommandResponse inside "data."
|
||||
type CommandResponseResponseWrapper struct {
|
||||
Data CommandResponseResponse `json:"data"`
|
||||
}
|
||||
|
||||
// CommandResponseRequest is the request message associated with a CommandResponse call.
|
||||
type CommandResponseRequest struct {
|
||||
ResponseToken string `json:"responseToken"`
|
||||
Response any `json:"response"`
|
||||
}
|
||||
|
||||
// DNClientCommandResponseResponse is the response message associated with a CommandResponse call.
|
||||
type CommandResponseResponse struct{}
|
||||
|
||||
type ClientInfo struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Version string `json:"version"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
}
|
||||
|
||||
// EnrollEndpoint is the REST enrollment endpoint.
|
||||
const EnrollEndpoint = "/v2/enroll"
|
||||
|
||||
// EnrollRequest is issued to the EnrollEndpoint.
|
||||
type EnrollRequest struct {
|
||||
Code string `json:"code"`
|
||||
CmeshPubkeyX25519 []byte `json:"dhPubkey"` // X25519 (used for key exchange)
|
||||
HostPubkeyEd25519 []byte `json:"edPubkey"` // Ed25519 (used for signing)
|
||||
CmeshPubkeyP256 []byte `json:"cmeshPubkeyP256"` // P256 (used for key exchange)
|
||||
HostPubkeyP256 []byte `json:"hostPubkeyP256"` // P256 (used for signing)
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// EnrollResponse represents a response from the enrollment endpoint.
|
||||
type EnrollResponse struct {
|
||||
// Only one of Data or Errors should be set in a response
|
||||
Data EnrollResponseData `json:"data"`
|
||||
|
||||
Errors APIErrors `json:"errors"`
|
||||
}
|
||||
|
||||
// EnrollResponseData is included in the EnrollResponse.
|
||||
type EnrollResponseData struct {
|
||||
Config []byte `json:"config"`
|
||||
HostID string `json:"hostID"`
|
||||
Counter uint `json:"counter"`
|
||||
TrustedKeys []byte `json:"trustedKeys"`
|
||||
Organization EnrollResponseDataOrg `json:"organization"`
|
||||
Network EnrollResponseDataNetwork `json:"network"`
|
||||
}
|
||||
|
||||
// EnrollResponseDataOrg is included in EnrollResponseData.
|
||||
type EnrollResponseDataOrg struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// EnrollResponseDataNetwork is included in EnrollResponseData.
|
||||
type EnrollResponseDataNetwork struct {
|
||||
ID string `json:"id"`
|
||||
Curve NetworkCurve `json:"curve"`
|
||||
}
|
||||
|
||||
// APIError represents a single error returned in an API error response.
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Path string `json:"path"` // may or may not be present
|
||||
}
|
||||
|
||||
type APIErrors []APIError
|
||||
|
||||
func (errs APIErrors) ToError() error {
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
s := make([]string, len(errs))
|
||||
for i := range errs {
|
||||
s[i] = errs[i].Message
|
||||
}
|
||||
|
||||
return errors.New(strings.Join(s, ", "))
|
||||
}
|
||||
|
||||
// NetworkCurve represents the network curve specified by the API.
|
||||
type NetworkCurve string
|
||||
|
||||
const (
|
||||
NetworkCurve25519 NetworkCurve = "25519"
|
||||
NetworkCurveP256 NetworkCurve = "P256"
|
||||
)
|
||||
|
||||
func (nc *NetworkCurve) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch s {
|
||||
case "25519":
|
||||
*nc = NetworkCurve25519
|
||||
case "P256":
|
||||
*nc = NetworkCurveP256
|
||||
default:
|
||||
return errors.New("invalid network curve")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
27
scripts/build/build.sh
Executable file
27
scripts/build/build.sh
Executable file
|
@ -0,0 +1,27 @@
|
|||
#!/bin/bash
|
||||
################################################################################
|
||||
# Intended use: Compile and extract software packages
|
||||
#
|
||||
# Notes: This script support Linux Bash shell script only
|
||||
# Install Bash from https://www.gnu.org/software/bash/
|
||||
#
|
||||
# Copyright (C) 2015 - 2025, CT129 Dev Team <dev@ct129.com>
|
||||
################################################################################
|
||||
|
||||
# Source path
|
||||
export SCRIPT=$(readlink -f "$0")
|
||||
export SCRIPT_PATH=$(dirname "$SCRIPT")
|
||||
export SRC_DIR=${SCRIPT_PATH}/../..
|
||||
export APP_NAME=$(basename $(dirname $(dirname "${SCRIPT_PATH}")))
|
||||
|
||||
cd ${SRC_DIR}
|
||||
|
||||
# Make for all *nix platfomr
|
||||
make all
|
||||
|
||||
# Make for MS Windows
|
||||
make bin-windows
|
||||
|
||||
################################################################################
|
||||
# BASH SCRIPT ON LINUX/UNIX - END
|
||||
################################################################################
|
25
scripts/util/util_git_push.sh
Executable file
25
scripts/util/util_git_push.sh
Executable file
|
@ -0,0 +1,25 @@
|
|||
#!/bin/bash
|
||||
################################################################################
|
||||
# Intended use: Compile and extract software packages
|
||||
#
|
||||
# Notes: This script support Linux Bash shell script only
|
||||
# Install Bash from https://www.gnu.org/software/bash/
|
||||
#
|
||||
# Copyright (C) 2015 - 2025, CT129 Dev Team <dev@ct129.com>
|
||||
################################################################################
|
||||
|
||||
export GIT_BRACNH="main"
|
||||
|
||||
export TIMESTAMP=`date +"%Y-%m-%d %H:%M:%S"`
|
||||
export SCRIPT=$(readlink -f "$0")
|
||||
export SCRIPT_PATH=$(dirname "$SCRIPT")
|
||||
export SRC_DIR=${SCRIPT_PATH}/../..
|
||||
|
||||
cd ${SRC_DIR}
|
||||
git add .
|
||||
git checkout ${GIT_BRACNH}
|
||||
git commit -m "${TIMESTAMP}"
|
||||
git push -u origin ${GIT_BRACNH}
|
||||
################################################################################
|
||||
# BASH SCRIPT ON LINUX/UNIX - END
|
||||
################################################################################
|
29
scripts/util/util_git_release.sh
Executable file
29
scripts/util/util_git_release.sh
Executable file
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
################################################################################
|
||||
# Intended use: Compile and extract software packages
|
||||
#
|
||||
# Notes: This script support Linux Bash shell script only
|
||||
# Install Bash from https://www.gnu.org/software/bash/
|
||||
#
|
||||
# Copyright (C) 2015 - 2025, CT129 Dev Team <dev@ct129.com>
|
||||
################################################################################
|
||||
|
||||
export GIT_BRACNH="main"
|
||||
export GIT_TAG="v1.9.5"
|
||||
|
||||
export TIMESTAMP=`date +"%Y-%m-%d %H:%M:%S"`
|
||||
export SCRIPT=$(readlink -f "$0")
|
||||
export SCRIPT_PATH=$(dirname "$SCRIPT")
|
||||
export SRC_DIR=${SCRIPT_PATH}/../..
|
||||
|
||||
cd ${SRC_DIR}
|
||||
git add .
|
||||
git checkout ${GIT_BRACNH}
|
||||
git commit -m "${TIMESTAMP}"
|
||||
git tag -d ${GIT_TAG}
|
||||
git push origin :refs/tags/${GIT_TAG}
|
||||
git tag -a ${GIT_TAG} -m ${GIT_TAG}
|
||||
git push --tags
|
||||
################################################################################
|
||||
# BASH SCRIPT ON LINUX/UNIX - END
|
||||
################################################################################
|
41
sign.go
Normal file
41
sign.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
package cmapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.ct129.com/VPN/cmapi/keys"
|
||||
"git.ct129.com/VPN/cmapi/message"
|
||||
)
|
||||
|
||||
func SignRequestV1(reqType string, value []byte, hostID string, counter uint, privkey keys.PrivateKey) ([]byte, error) {
|
||||
encMsg, err := json.Marshal(message.RequestWrapper{
|
||||
Type: reqType,
|
||||
Value: value,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signedMsg := base64.StdEncoding.EncodeToString(encMsg)
|
||||
sig, err := privkey.Sign([]byte(signedMsg))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wrapper := message.RequestV1{
|
||||
Version: 1,
|
||||
HostID: hostID,
|
||||
Counter: counter,
|
||||
Message: signedMsg,
|
||||
Signature: sig,
|
||||
}
|
||||
b, err := json.Marshal(wrapper)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue