Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
138 lines
5.7 KiB
Go
138 lines
5.7 KiB
Go
// Copyright © 2025 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package webauthn_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
|
|
"github.com/ory/x/configx"
|
|
|
|
"github.com/ory/kratos/driver/config"
|
|
"github.com/ory/kratos/identity"
|
|
"github.com/ory/kratos/pkg"
|
|
kratos "github.com/ory/kratos/pkg/httpclient"
|
|
"github.com/ory/kratos/pkg/testhelpers"
|
|
"github.com/ory/kratos/ui/node"
|
|
)
|
|
|
|
// TestLoginUpdatesSignCount is a regression test for HackerOne #3780149.
|
|
//
|
|
// The go-webauthn library returns a *webauthn.Credential from ValidateLogin
|
|
// whose Authenticator.SignCount reflects the signature counter reported by the
|
|
// authenticator during the assertion. W3C WebAuthn clone detection relies on
|
|
// the relying party persisting this incremented counter so that a replayed or
|
|
// cloned authenticator (which reports a stale counter) can be detected.
|
|
//
|
|
// Before the fix, Kratos discarded the returned credential, so the stored
|
|
// SignCount stayed at its registration value forever and clone detection was
|
|
// inert.
|
|
//
|
|
// The MFA v1 fixtures store sign_count = 3 and the assertion response reports
|
|
// counter = 10, so a correct implementation persists SignCount = 10.
|
|
func TestLoginUpdatesSignCount(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
conf, reg := pkg.NewFastRegistryWithMocks(t,
|
|
configx.WithValue(config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword)+".enabled", false),
|
|
enabledWebauthn,
|
|
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")),
|
|
)
|
|
|
|
publicTS, _ := testhelpers.NewKratosServer(t, reg)
|
|
_ = testhelpers.NewErrorTestServer(t, reg)
|
|
_ = testhelpers.NewLoginUIFlowEchoServer(t, reg)
|
|
_ = testhelpers.NewRedirSessionEchoTS(t, reg)
|
|
|
|
createIdentityWithWebAuthn := func(t *testing.T, c identity.Credentials) *identity.Identity {
|
|
var id identity.Identity
|
|
require.NoError(t, json.Unmarshal(loginFixtureSuccessIdentity, &id))
|
|
|
|
id.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{
|
|
Identifiers: []string{loginFixtureSuccessEmail},
|
|
Config: c.Config,
|
|
Type: identity.CredentialsTypeWebAuthn,
|
|
Version: c.Version,
|
|
})
|
|
|
|
_ = reg.PrivilegedIdentityPool().DeleteIdentity(t.Context(), id.ID)
|
|
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(t.Context(), &id))
|
|
return &id
|
|
}
|
|
|
|
submitWebAuthnLogin := func(t *testing.T, isSPA bool, id *identity.Identity, contextFixture []byte, cb func(values url.Values), opts ...testhelpers.InitFlowWithOption) (string, *http.Response, *kratos.LoginFlow) {
|
|
client := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
|
|
f := testhelpers.InitializeLoginFlowViaBrowser(t, client, publicTS, false, isSPA, false, false, opts...)
|
|
|
|
interim, err := reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(f.Id))
|
|
require.NoError(t, err)
|
|
interim.InternalContext = contextFixture
|
|
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), interim))
|
|
|
|
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
|
cb(values)
|
|
body, res := testhelpers.LoginMakeRequest(t, false, isSPA, f, client, values.Encode())
|
|
return body, res, f
|
|
}
|
|
|
|
// Reads the stored first webauthn credential: the outer row (for id and
|
|
// created_at) and the decoded authenticator state.
|
|
storedCredential := func(t *testing.T, idID uuid.UUID) (*identity.Credentials, *identity.AuthenticatorWebAuthn) {
|
|
stored, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), idID)
|
|
require.NoError(t, err)
|
|
c, ok := stored.GetCredentials(identity.CredentialsTypeWebAuthn)
|
|
require.True(t, ok)
|
|
var conf identity.CredentialsWebAuthnConfig
|
|
require.NoError(t, json.Unmarshal(c.Config, &conf))
|
|
require.NotEmpty(t, conf.Credentials)
|
|
require.NotNil(t, conf.Credentials[0].Authenticator)
|
|
return c, conf.Credentials[0].Authenticator
|
|
}
|
|
|
|
conf.MustSet(t.Context(), config.ViperKeySessionWhoAmIAAL, "aal1")
|
|
|
|
id := createIdentityWithWebAuthn(t, identity.Credentials{
|
|
Config: loginFixtureSuccessV1Credentials,
|
|
Version: 1,
|
|
})
|
|
|
|
// Sanity: the registered credential starts at the fixture's sign_count = 3.
|
|
credBefore, authBefore := storedCredential(t, id.ID)
|
|
require.EqualValues(t, 3, authBefore.SignCount)
|
|
|
|
body, _, _ := submitWebAuthnLogin(t, true, id, loginFixtureSuccessV1Context, func(values url.Values) {
|
|
values.Set("identifier", loginFixtureSuccessEmail)
|
|
values.Set(node.WebAuthnLogin, string(loginFixtureSuccessV1Response))
|
|
}, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
|
|
|
|
// The login must succeed.
|
|
require.True(t, gjson.Get(body, "session.active").Bool(), "%s", body)
|
|
|
|
// The stored SignCount must be updated to the value reported by the
|
|
// authenticator assertion (10), not left at the registration value (3).
|
|
credAfter, auth := storedCredential(t, id.ID)
|
|
assert.EqualValues(t, 10, auth.SignCount,
|
|
"stored SignCount must be updated from the login assertion for clone detection to work")
|
|
|
|
// The assertion counter advanced (3 -> 10), so this is not a clone: the
|
|
// persisted clone warning must round-trip as false rather than being set
|
|
// spuriously on a normal login.
|
|
assert.False(t, auth.CloneWarning,
|
|
"a normally advancing counter must not set the clone warning")
|
|
|
|
// The credential row must be updated in place, not deleted and recreated:
|
|
// its id and created_at must be stable across the login.
|
|
assert.Equal(t, credBefore.ID, credAfter.ID,
|
|
"the credential row must be updated in place (stable id), not recreated")
|
|
assert.Equal(t, credBefore.CreatedAt, credAfter.CreatedAt,
|
|
"persisting the sign counter must not reset the credential's created_at")
|
|
}
|