tests/identity_connection_test.go
Rama master · réplica de lectura del repositorio original.
package main
import (
"context"
"sync"
"testing"
"time"
"github.com/shellhub-io/shellhub/pkg/api/authorizer"
"github.com/shellhub-io/shellhub/pkg/api/requests"
"github.com/shellhub-io/shellhub/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
const deadIdentityLog = "failed to resolve the identity for the public key"
// TestSSHIdentityConnection covers what an enrolled identity does at connect time once it exists:
// it stops working when it expires or when its one session burns it, every connection stamps it,
// and it authenticates as the principal it is bound to and nobody else. Enrolling a key and the
// first connection with it are covered by [TestSSHNewNamespace] and [TestIdentityAccessPolicy].
func TestSSHIdentityConnection(t *testing.T) {
t.Parallel()
ctx := context.Background()
compose := newSSHEnvironment(t, ctx, newNamespace)
_, device := startAcceptedAgent(t, ctx, compose)
sshid := deviceSSHID(device)
t.Run("an expired identity is refused instead of being offered for enrollment again", func(t *testing.T) {
signer, data := newSigner(t)
fingerprint := ssh.FingerprintSHA256(signer.PublicKey())
days := 1
resp, err := compose.R(t.Context()).
SetBody(&requests.SSHIdentityCreate{Name: "expiring", Data: data, ExpiresIn: &days}).
Post("/api/ssh-identities")
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode(), resp.String())
requireStraightThrough(t, compose, sshid, signer)
expireIdentityAndRequireRefused(t, compose, sshid, signer)
identity := identityByFingerprint(t, compose, fingerprint)
assert.False(t, identity.Active(time.Now()), //nolint:forbidigo // the expiry the server compares against its own wall clock
"the identity should still be on record, and dead")
})
t.Run("a single-use identity is consumed by its first session", func(t *testing.T) {
key, signer := newAPIKeyIdentity(t, compose, "oneshot", true)
grant(t, compose, &requests.AccessPolicyCreate{
Name: "oneshot",
Subject: apiKeySubject(key.ID),
Filter: requests.AccessPolicyFilter{All: true},
Logins: []string{"*"},
})
fresh := apiKeyIdentity(t, compose, "oneshot")
require.True(t, fresh.SingleUse)
require.Nil(t, fresh.ConsumedAt)
requireStraightThrough(t, compose, sshid, signer)
requireRecent(t, apiKeyIdentity(t, compose, "oneshot").ConsumedAt)
})
t.Run("a consumed single-use identity is refused", func(t *testing.T) {
key, signer := newAPIKeyIdentity(t, compose, "burnt", true)
grant(t, compose, &requests.AccessPolicyCreate{
Name: "burnt",
Subject: apiKeySubject(key.ID),
Filter: requests.AccessPolicyFilter{All: true},
Logins: []string{"*"},
})
requireStraightThrough(t, compose, sshid, signer)
require.NotNil(t, apiKeyIdentity(t, compose, "burnt").ConsumedAt)
mark := compose.ServerLogMark(t)
requireRefusedAtAuth(t, compose, sshid, signer)
compose.AwaitServerLogLine(t, mark, deadIdentityLog, `error="ssh access denied by policy"`)
})
t.Run("a single-use identity authenticates one of its concurrent logins", func(t *testing.T) {
key, signer := newAPIKeyIdentity(t, compose, "contended", true)
grant(t, compose, &requests.AccessPolicyCreate{
Name: "contended",
Subject: apiKeySubject(key.ID),
Filter: requests.AccessPolicyFilter{All: true},
Logins: []string{"*"},
})
const logins = 8
start := make(chan struct{})
results := make(chan error, logins)
var wg sync.WaitGroup
for range logins {
spare, _ := newSigner(t)
wg.Go(func() {
<-start
results <- dialSSH(t.Context(), compose.SSHAddress(), sshid, []ssh.Signer{signer, spare}, nil, nil)
})
}
close(start)
wg.Wait()
close(results)
authenticated := 0
for err := range results {
if err != nil {
require.ErrorContains(t, err, "unable to authenticate")
continue
}
authenticated++
}
assert.Equal(t, 1, authenticated)
requireRecent(t, apiKeyIdentity(t, compose, "contended").ConsumedAt)
})
t.Run("every connection stamps the identity's last use", func(t *testing.T) {
signer, fingerprint := enrollOwnerKey(t, compose)
require.Nil(t, identityByFingerprint(t, compose, fingerprint).LastUsedAt)
requireStraightThrough(t, compose, sshid, signer)
requireRecent(t, identityByFingerprint(t, compose, fingerprint).LastUsedAt)
compose.AgeSSHIdentityLastUse(t, fingerprint, 24*time.Hour)
aged := identityByFingerprint(t, compose, fingerprint).LastUsedAt
require.NotNil(t, aged)
require.WithinDuration(t, time.Now().Add(-24*time.Hour), *aged, time.Minute) //nolint:forbidigo // the stamp was aged with the database's wall clock
requireStraightThrough(t, compose, sshid, signer)
requireRecent(t, identityByFingerprint(t, compose, fingerprint).LastUsedAt)
})
t.Run("a member's key authenticates as that member, not as the owner a policy grants", func(t *testing.T) {
member := newMember(t, compose, "crossing", authorizer.RoleOperator)
memberSigner, data := newSigner(t)
compose.EnrollIdentityAs(t, member.Token, "member", data)
ownerSigner, _ := enrollOwnerKey(t, compose)
requireStraightThrough(t, compose, sshid, ownerSigner)
mark := compose.ServerLogMark(t)
requireRefusedAtAuth(t, compose, sshid, memberSigner)
compose.AwaitServerLogLine(t, mark, "reason="+string(models.ReasonNoGrant), "user="+member.ID)
identity := identityByFingerprint(t, compose, ssh.FingerprintSHA256(memberSigner.PublicKey()))
assert.Equal(t, member.ID, identity.PrincipalID)
})
}