Files
backrest/internal/api/syncapi/pairing_test.go

425 lines
11 KiB
Go

package syncapi
import (
"context"
"fmt"
"sync"
"testing"
"time"
v1 "github.com/garethgeorge/backrest/gen/go/v1"
"github.com/garethgeorge/backrest/gen/go/v1sync"
"github.com/garethgeorge/backrest/internal/config/migrations"
"github.com/garethgeorge/backrest/internal/cryptoutil"
"github.com/garethgeorge/backrest/internal/testutil"
)
func TestValidatePairingSecret(t *testing.T) {
now := time.Unix(1000, 0)
tokens := []*v1.Multihost_PairingToken{
{
Secret: "valid-secret",
Label: "test-token",
CreatedAtUnix: 900,
ExpiresAtUnix: 2000,
MaxUses: 3,
Uses: 1,
},
{
Secret: "unlimited-token",
Label: "unlimited",
CreatedAtUnix: 900,
ExpiresAtUnix: 0, // no expiry
MaxUses: 0, // unlimited uses
Uses: 100,
},
}
tests := []struct {
name string
secret string
tokens []*v1.Multihost_PairingToken
now time.Time
wantLabel string
wantErr bool
}{
{
name: "valid secret",
secret: "valid-secret",
tokens: tokens,
now: now,
wantLabel: "test-token",
},
{
name: "unlimited token",
secret: "unlimited-token",
tokens: tokens,
now: now,
wantLabel: "unlimited",
},
{
name: "empty secret",
secret: "",
tokens: tokens,
now: now,
wantErr: true,
},
{
name: "wrong secret",
secret: "wrong-secret",
tokens: tokens,
now: now,
wantErr: true,
},
{
name: "expired token",
secret: "valid-secret",
tokens: []*v1.Multihost_PairingToken{
{
Secret: "valid-secret",
Label: "expired",
ExpiresAtUnix: 500,
MaxUses: 0,
},
},
now: now,
wantErr: true,
},
{
name: "max uses reached",
secret: "valid-secret",
tokens: []*v1.Multihost_PairingToken{
{
Secret: "valid-secret",
Label: "exhausted",
MaxUses: 2,
Uses: 2,
},
},
now: now,
wantErr: true,
},
{
name: "nil tokens",
secret: "anything",
tokens: nil,
now: now,
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
token, err := ValidatePairingSecret(tc.secret, tc.tokens, tc.now)
if tc.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token.Label != tc.wantLabel {
t.Errorf("label = %q, want %q", token.Label, tc.wantLabel)
}
})
}
}
func TestPairingTokenFlow(t *testing.T) {
testutil.InstallZapLogger(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
peerHostAddr := testutil.AllocOpenBindAddr(t)
peerClientAddr := testutil.AllocOpenBindAddr(t)
pairingSecret, err := cryptoutil.GeneratePairingSecret()
if err != nil {
t.Fatalf("failed to generate pairing secret: %v", err)
}
// Host has a pairing token but NO authorized clients yet.
peerHostConfig := &v1.Config{
Version: migrations.CurrentVersion,
Instance: defaultHostID,
Repos: []*v1.Repo{},
Multihost: &v1.Multihost{
Identity: identity1,
AuthorizedClients: []*v1.Multihost_Peer{}, // empty — client not pre-authorized
PairingTokens: []*v1.Multihost_PairingToken{
{
Secret: pairingSecret,
Label: "test-pairing",
CreatedAtUnix: time.Now().Unix(),
ExpiresAtUnix: time.Now().Add(1 * time.Hour).Unix(),
MaxUses: 1,
Uses: 0,
},
},
},
}
// Client knows about the host and has the pairing secret.
peerClientConfig := &v1.Config{
Version: migrations.CurrentVersion,
Instance: defaultClientID,
Repos: []*v1.Repo{},
Multihost: &v1.Multihost{
Identity: identity2,
KnownHosts: []*v1.Multihost_Peer{
{
Keyid: identity1.Keyid,
InstanceId: defaultHostID,
InstanceUrl: fmt.Sprintf("http://%s", peerHostAddr),
InitialPairingSecret: pairingSecret,
},
},
},
}
peerHost := newPeerUnderTest(t, peerHostConfig)
peerClient := newPeerUnderTest(t, peerClientConfig)
startRunningSyncAPI(t, peerHost, peerHostAddr)
startRunningSyncAPI(t, peerClient, peerClientAddr)
// The client should successfully connect via the pairing token.
tryConnect(t, ctx, peerClient, peerClientConfig.Multihost.KnownHosts[0])
// The server pairs the client during its own runSync handshake handling, which
// runs concurrently with — and may finish slightly after — the client reaching
// CONNECTED state. Poll for the host config to reflect the pairing.
var hostConfig *v1.Config
testutil.Try(t, ctx, func() error {
var err error
hostConfig, err = peerHost.configMgr.Get()
if err != nil {
return fmt.Errorf("get host config: %w", err)
}
if len(hostConfig.Multihost.AuthorizedClients) != 1 {
return fmt.Errorf("expected 1 authorized client, got %d", len(hostConfig.Multihost.AuthorizedClients))
}
return nil
})
ac := hostConfig.Multihost.AuthorizedClients[0]
if ac.Keyid != identity2.Keyid {
t.Errorf("authorized client keyid = %q, want %q", ac.Keyid, identity2.Keyid)
}
if ac.InstanceId != defaultClientID {
t.Errorf("authorized client instance id = %q, want %q", ac.InstanceId, defaultClientID)
}
// Verify the pairing token was consumed (max_uses=1, so it should be removed).
if len(hostConfig.Multihost.PairingTokens) != 0 {
t.Errorf("expected 0 pairing tokens after consumption, got %d", len(hostConfig.Multihost.PairingTokens))
}
}
func TestPairingTokenExpiredRejected(t *testing.T) {
testutil.InstallZapLogger(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
peerHostAddr := testutil.AllocOpenBindAddr(t)
peerClientAddr := testutil.AllocOpenBindAddr(t)
pairingSecret, _ := cryptoutil.GeneratePairingSecret()
// Host has an expired pairing token.
peerHostConfig := &v1.Config{
Version: migrations.CurrentVersion,
Instance: defaultHostID,
Repos: []*v1.Repo{},
Multihost: &v1.Multihost{
Identity: identity1,
AuthorizedClients: []*v1.Multihost_Peer{},
PairingTokens: []*v1.Multihost_PairingToken{
{
Secret: pairingSecret,
Label: "expired-token",
CreatedAtUnix: time.Now().Add(-2 * time.Hour).Unix(),
ExpiresAtUnix: time.Now().Add(-1 * time.Hour).Unix(), // expired 1 hour ago
MaxUses: 0,
},
},
},
}
peerClientConfig := &v1.Config{
Version: migrations.CurrentVersion,
Instance: defaultClientID,
Repos: []*v1.Repo{},
Multihost: &v1.Multihost{
Identity: identity2,
KnownHosts: []*v1.Multihost_Peer{
{
Keyid: identity1.Keyid,
InstanceId: defaultHostID,
InstanceUrl: fmt.Sprintf("http://%s", peerHostAddr),
InitialPairingSecret: pairingSecret,
},
},
},
}
peerHost := newPeerUnderTest(t, peerHostConfig)
peerClient := newPeerUnderTest(t, peerClientConfig)
startRunningSyncAPI(t, peerHost, peerHostAddr)
startRunningSyncAPI(t, peerClient, peerClientAddr)
// Connection should fail with auth error.
waitForConnectionState(t, ctx, peerClient, peerClientConfig.Multihost.KnownHosts[0], v1sync.ConnectionState_CONNECTION_STATE_ERROR_AUTH)
// Host should still have no authorized clients.
hostConfig, _ := peerHost.configMgr.Get()
if len(hostConfig.Multihost.AuthorizedClients) != 0 {
t.Errorf("expected 0 authorized clients, got %d", len(hostConfig.Multihost.AuthorizedClients))
}
}
func TestPairingTokenMaxUsesEnforced(t *testing.T) {
testutil.InstallZapLogger(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
peerHostAddr := testutil.AllocOpenBindAddr(t)
pairingSecret, _ := cryptoutil.GeneratePairingSecret()
identity3, _ := cryptoutil.GeneratePrivateKey()
// Host has a pairing token with max_uses=1.
peerHostConfig := &v1.Config{
Version: migrations.CurrentVersion,
Instance: defaultHostID,
Repos: []*v1.Repo{},
Multihost: &v1.Multihost{
Identity: identity1,
AuthorizedClients: []*v1.Multihost_Peer{},
PairingTokens: []*v1.Multihost_PairingToken{
{
Secret: pairingSecret,
Label: "single-use",
CreatedAtUnix: time.Now().Unix(),
ExpiresAtUnix: time.Now().Add(1 * time.Hour).Unix(),
MaxUses: 1,
Uses: 0,
},
},
},
}
// First client pairs successfully.
peerClient1Config := &v1.Config{
Version: migrations.CurrentVersion,
Instance: "client-1",
Repos: []*v1.Repo{},
Multihost: &v1.Multihost{
Identity: identity2,
KnownHosts: []*v1.Multihost_Peer{
{
Keyid: identity1.Keyid,
InstanceId: defaultHostID,
InstanceUrl: fmt.Sprintf("http://%s", peerHostAddr),
InitialPairingSecret: pairingSecret,
},
},
},
}
peerHost := newPeerUnderTest(t, peerHostConfig)
peerClient1 := newPeerUnderTest(t, peerClient1Config)
var wg sync.WaitGroup
syncCtx, cancelSync := context.WithCancel(ctx)
wg.Add(2)
go func() { defer wg.Done(); runSyncAPIWithCtx(syncCtx, peerHost, peerHostAddr) }()
go func() {
defer wg.Done()
peerClient1Addr := testutil.AllocOpenBindAddr(t)
runSyncAPIWithCtx(syncCtx, peerClient1, peerClient1Addr)
}()
tryConnect(t, ctx, peerClient1, peerClient1Config.Multihost.KnownHosts[0])
// Stop first client, start second client with same pairing secret.
cancelSync()
wg.Wait()
peerClient2Config := &v1.Config{
Version: migrations.CurrentVersion,
Instance: "client-2",
Repos: []*v1.Repo{},
Multihost: &v1.Multihost{
Identity: identity3,
KnownHosts: []*v1.Multihost_Peer{
{
Keyid: identity1.Keyid,
InstanceId: defaultHostID,
InstanceUrl: fmt.Sprintf("http://%s", peerHostAddr),
InitialPairingSecret: pairingSecret,
},
},
},
}
peerClient2 := newPeerUnderTest(t, peerClient2Config)
startRunningSyncAPI(t, peerHost, peerHostAddr)
startRunningSyncAPI(t, peerClient2, testutil.AllocOpenBindAddr(t))
// Second client should fail — token is consumed.
waitForConnectionState(t, ctx, peerClient2, peerClient2Config.Multihost.KnownHosts[0], v1sync.ConnectionState_CONNECTION_STATE_ERROR_AUTH)
}
func TestNoPairingSecretRejected(t *testing.T) {
testutil.InstallZapLogger(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
peerHostAddr := testutil.AllocOpenBindAddr(t)
peerClientAddr := testutil.AllocOpenBindAddr(t)
// Host has NO pairing tokens and NO authorized clients.
peerHostConfig := &v1.Config{
Version: migrations.CurrentVersion,
Instance: defaultHostID,
Repos: []*v1.Repo{},
Multihost: &v1.Multihost{
Identity: identity1,
AuthorizedClients: []*v1.Multihost_Peer{},
},
}
// Client tries to connect without any pairing secret.
peerClientConfig := &v1.Config{
Version: migrations.CurrentVersion,
Instance: defaultClientID,
Repos: []*v1.Repo{},
Multihost: &v1.Multihost{
Identity: identity2,
KnownHosts: []*v1.Multihost_Peer{
{
Keyid: identity1.Keyid,
InstanceId: defaultHostID,
InstanceUrl: fmt.Sprintf("http://%s", peerHostAddr),
},
},
},
}
peerHost := newPeerUnderTest(t, peerHostConfig)
peerClient := newPeerUnderTest(t, peerClientConfig)
startRunningSyncAPI(t, peerHost, peerHostAddr)
startRunningSyncAPI(t, peerClient, peerClientAddr)
waitForConnectionState(t, ctx, peerClient, peerClientConfig.Multihost.KnownHosts[0], v1sync.ConnectionState_CONNECTION_STATE_ERROR_AUTH)
}