diff --git a/internal/api/api_test.go b/internal/api/api_test.go index dbe3afc..3fa78e3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -460,6 +460,23 @@ func (f *fakeRepo) DeleteAllPasskeyCredentialsForUser(_ context.Context, userID return nil } +// AdvanceCredentialSignCount mirrors PGRepo: find the passkey by its UNIQUE credential_id, set +// the stored counter to newSignCount, and stamp last_used_at. A credential_id matching no row +// is a successful no-op (the PG UPDATE touches zero rows), so a test can assert both the +// advance-on-success and the never-error-on-missing contracts. +func (f *fakeRepo) AdvanceCredentialSignCount(_ context.Context, credentialID string, newSignCount uint32, usedAt time.Time) error { + for id, c := range f.passkeyCreds { + if c.CredentialID == credentialID { + c.SignCount = newSignCount + t := usedAt + c.LastUsedAt = &t + f.passkeyCreds[id] = c + return nil + } + } + return nil +} + // fakePasskeyVerifier is the hermetic PasskeyVerifier: it performs no real attestation // or assertion crypto, so it exercises the enrollment AND login STATE MACHINES (challenge // persistence, consume, conflict, audit, session mint) without go-webauthn. diff --git a/internal/api/handlers_passkey.go b/internal/api/handlers_passkey.go index 652bb19..7dc3b44 100644 --- a/internal/api/handlers_passkey.go +++ b/internal/api/handlers_passkey.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "crypto/rand" "encoding/hex" "encoding/json" @@ -154,21 +155,25 @@ type VerifiedCredential struct { } // VerifiedAssertion is the output of a finished LOGIN (assertion) ceremony: which of the -// user's bound credentials proved itself and the signature counter the authenticator -// reported. Like VerifiedCredential it carries no secret. SignCount is the raw ceremony -// fact, NOT a policy verdict: the handler that eventually consumes this holds the -// previously-stored counter and decides whether a non-increase is a cloned-authenticator -// signal — the verifier deliberately does not, so clone policy lives in one place with -// the stored state. SignCount is legitimately 0 for authenticators that keep no counter. +// user's bound credentials proved itself, the signature counter the authenticator reported, +// and whether that counter regressed (a possible clone). Like VerifiedCredential it carries +// no secret. SignCount is a raw ceremony fact, NOT a policy verdict; CloneWarning IS the +// verifier's regression verdict, but the refuse-vs-allow decision is the handler's. Clone +// policy therefore lives in one place with the stored counter (applyAssertionCounter, which +// both login doors call). SignCount is legitimately 0 for authenticators that keep no counter. // -// The login handler below (handlePasskeyLoginFinish) obtains this from FinishLogin but -// currently checks only that the assertion verified — the SignCount/UserVerified consumer -// the note above anticipates is still future. It is the stable seam output the production -// adapter (internal/passkey) produces and its Oracle test asserts on, so handler and -// adapter agree on shape without either reshaping the other. +// applyAssertionCounter is that single consumer: it refuses a CloneWarning fail-closed and, +// on success, advances the stored counter and stamps last_used_at. This is the stable seam +// output the production adapter (internal/passkey) produces and its Oracle test asserts on, +// so handler and adapter agree on shape without either reshaping the other. type VerifiedAssertion struct { CredentialID string // base64url(raw credential id) — which bound credential signed SignCount uint32 + // CloneWarning is go-webauthn's verdict that the signature counter did not advance past + // the stored value (WebAuthn §6.1.1 clone detection). It is meaningful only for + // counter-keeping authenticators: synced/counter-less keys report SignCount 0 on every + // assertion and structurally never raise it. The login handlers refuse it fail-closed. + CloneWarning bool // UserVerified records that a PIN/biometric (not mere presence) was performed // during the assertion ceremony. The verifier enforces UV=required at BeginLogin, // so this is always true for a successful assertion; persisting it makes the @@ -181,6 +186,28 @@ type VerifiedAssertion struct { var errPasskeyUnavailable = newError(http.StatusServiceUnavailable, "passkey_unavailable", "passkey subsystem is not configured") +// errPasskeyClonedAuthenticator is the internal signal from applyAssertionCounter that a +// verified assertion carried a clone warning (its signature counter did not advance past the +// stored value). It never reaches the client verbatim: the login doors map it to the generic +// passkey_login_invalid envelope — no clone oracle to a prober — and audit it distinctly. +var errPasskeyClonedAuthenticator = errors.New("passkey assertion rejected: clone warning") + +// applyAssertionCounter is the single consumer of a verified assertion's signature-counter +// facts, shared by the username-first (handlePasskeyLoginFinish) and discoverable +// (handlePasskeyLoginDiscoverableFinish) login doors so clone policy lives in one place with +// the stored counter. A CloneWarning fails closed with errPasskeyClonedAuthenticator; +// otherwise it advances the stored counter to the asserted value and stamps last_used_at. +// Counter-less/synced authenticators report 0 and never warn, so they pass through and simply +// re-stamp 0 — the check gates only counter-keeping authenticators, where a rollback is the +// meaningful clone signal. It runs BEFORE the session is minted, so a clone or a persist +// failure denies the login rather than leaving an advanced counter with no session. +func (a *API) applyAssertionCounter(ctx context.Context, va VerifiedAssertion) error { + if va.CloneWarning { + return errPasskeyClonedAuthenticator + } + return a.Repo.AdvanceCredentialSignCount(ctx, va.CredentialID, va.SignCount, a.now()) +} + // newPasskeyID returns an opaque random row id (128 bits, hex) for a passkey row. func newPasskeyID() (string, error) { var b [16]byte @@ -577,12 +604,25 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) { DisplayName: u.Username, Credentials: creds, } - _, err = a.Passkey.FinishLogin(user, sessionData, bytes.NewReader(req.Assertion)) + va, err := a.Passkey.FinishLogin(user, sessionData, bytes.NewReader(req.Assertion)) if err != nil { writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", "passkey login could not be completed; begin again")) return } + // Clone policy + counter advance, in one place shared with the discoverable door. A + // regressed counter is refused with the same opaque envelope (no clone oracle) but audited + // distinctly; a successful assertion advances the stored counter and stamps last_used_at. + if err := a.applyAssertionCounter(r.Context(), va); err != nil { + if errors.Is(err, errPasskeyClonedAuthenticator) { + a.audit(r, u.Username, "auth.passkey_clone_rejected", va.CredentialID) + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", + "passkey login could not be completed; begin again")) + return + } + writeError(w, r, err) + return + } token, err := newSessionToken() if err != nil { diff --git a/internal/api/handlers_passkey_discoverable.go b/internal/api/handlers_passkey_discoverable.go index 6e18a56..1773e80 100644 --- a/internal/api/handlers_passkey_discoverable.go +++ b/internal/api/handlers_passkey_discoverable.go @@ -157,7 +157,8 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt // stable username so a nil email never matters. return PasskeyUser{ID: u.ID, Name: u.Username, DisplayName: u.Username, Credentials: creds}, nil } - if _, err := a.Passkey.FinishDiscoverableLogin(resolve, sessionData, bytes.NewReader(req.Assertion)); err != nil { + va, err := a.Passkey.FinishDiscoverableLogin(resolve, sessionData, bytes.NewReader(req.Assertion)) + if err != nil { writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", "passkey login could not be completed; begin again")) return @@ -171,6 +172,19 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt "passkey login could not be completed; begin again")) return } + // Same clone policy + counter advance as the username-first door (applyAssertionCounter): a + // regressed counter is refused with the identical opaque envelope but audited under the + // resolved account; a successful assertion advances the stored counter and stamps last_used_at. + if err := a.applyAssertionCounter(r.Context(), va); err != nil { + if errors.Is(err, errPasskeyClonedAuthenticator) { + a.audit(r, resolved.Username, "auth.passkey_clone_rejected", va.CredentialID) + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", + "passkey login could not be completed; begin again")) + return + } + writeError(w, r, err) + return + } token, err := newSessionToken() if err != nil { diff --git a/internal/api/handlers_passkey_discoverable_test.go b/internal/api/handlers_passkey_discoverable_test.go index 08bd78d..5f4833c 100644 --- a/internal/api/handlers_passkey_discoverable_test.go +++ b/internal/api/handlers_passkey_discoverable_test.go @@ -317,3 +317,81 @@ func TestPasskeyDiscoverableLoginFaceSeparation(t *testing.T) { t.Errorf("finish on internal face: code = %d, want 404", w.Code) } } + +// beginDiscoverableLogin runs the from-zero begin and returns the stashed login_id, so the +// counter/clone tests below need not re-inline the begin ceremony each time. +func beginDiscoverableLogin(t *testing.T, eh http.Handler) string { + t.Helper() + w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + loginID, _ := acctBody(t, w)["login_id"].(string) + if loginID == "" { + t.Fatalf("begin returned empty login_id: %s", w.Body.String()) + } + return loginID +} + +// TestPasskeyDiscoverableLoginAdvancesSignCount proves the success half of task #40 item 5: a +// verified from-zero assertion advances the stored signature counter to the value the +// authenticator reported and stamps last_used_at. Without this the stored counter would sit at +// the enrollment-time 0 forever, leaving the clone check below no moving baseline to judge a +// later regression against. +func TestPasskeyDiscoverableLoginAdvancesSignCount(t *testing.T) { + api, repo, v := seedDiscoverableLoginAPI(t) + // A clean (non-clone) assertion reporting an advanced counter. + v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, SignCount: 42} + eh := api.ExternalHandler() + + loginID := beginDiscoverableLogin(t, eh) + w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish", + `{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + got := repo.passkeyCreds["row1"] + if got.SignCount != 42 { + t.Errorf("stored SignCount = %d, want 42 (advanced to the asserted counter)", got.SignCount) + } + if got.LastUsedAt == nil || !got.LastUsedAt.Equal(frozenNow) { + t.Errorf("stored LastUsedAt = %v, want %v (stamped on a successful assertion)", got.LastUsedAt, frozenNow) + } +} + +// TestPasskeyDiscoverableLoginCloneRejected proves the fail-closed half of task #40 item 5: a +// verified assertion carrying a CloneWarning (signature-counter regression — a possible cloned +// authenticator) is refused. The refusal (1) collapses into the same passkey_login_invalid +// envelope as any other finish failure so a prober gets no clone oracle, (2) mints NO session, +// (3) does NOT advance or stamp the stored credential, and (4) is audited distinctly for the +// operator under the resolved account. +func TestPasskeyDiscoverableLoginCloneRejected(t *testing.T) { + api, repo, v := seedDiscoverableLoginAPI(t) + v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, SignCount: 3, CloneWarning: true} + eh := api.ExternalHandler() + + loginID := beginDiscoverableLogin(t, eh) + w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish", + `{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader) + + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" { + t.Fatalf("clone finish: code = %d body %s, want 400 passkey_login_invalid (opaque refusal)", w.Code, w.Body.String()) + } + if len(repo.sessions) != 0 { + t.Errorf("clone refusal must mint no session, got %d", len(repo.sessions)) + } + if len(w.Result().Cookies()) != 0 { + t.Errorf("clone refusal must set no session cookie, got %v", w.Result().Cookies()) + } + // The stored credential is untouched: still at enrollment-time counter 0, never stamped. + if got := repo.passkeyCreds["row1"]; got.SignCount != 0 || got.LastUsedAt != nil { + t.Errorf("clone refusal must not advance/stamp the credential, got SignCount=%d LastUsedAt=%v", got.SignCount, got.LastUsedAt) + } + // Audited distinctly, under the resolved account, so the operator sees the clone signal. + if n := len(repo.audits); n != 1 || repo.audits[0].Action != "auth.passkey_clone_rejected" { + t.Fatalf("want exactly 1 auth.passkey_clone_rejected audit, got %+v", repo.audits) + } + if repo.audits[0].Actor != "player" { + t.Errorf("clone audit actor = %q, want player (the resolved account)", repo.audits[0].Actor) + } +} diff --git a/internal/api/handlers_passkey_login_test.go b/internal/api/handlers_passkey_login_test.go index c64d0b9..78496ee 100644 --- a/internal/api/handlers_passkey_login_test.go +++ b/internal/api/handlers_passkey_login_test.go @@ -458,3 +458,39 @@ func TestPasskeyLoginFaceSeparation(t *testing.T) { t.Errorf("finish on internal face: code = %d, want 404", w.Code) } } + +// TestPasskeyLoginFinishCloneRejected is the username-first mirror of the discoverable door's +// clone refusal (task #40 item 5). Both doors share applyAssertionCounter, but each WIRES it +// independently, so this proves the username-first finish also fails closed on a CloneWarning: +// the same opaque passkey_login_invalid envelope (no clone oracle), no session minted, the stored +// credential left untouched at its enrollment-time counter, and a distinct auth.passkey_clone_ +// rejected audit under the account. Challenge is planted directly so finish is reachable under +// the frozen clock without a live begin. +func TestPasskeyLoginFinishCloneRejected(t *testing.T) { + api, repo, v := seedLoginPasskeyAPI(t) + v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, SignCount: 3, CloneWarning: true} + plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL)) + eh := api.ExternalHandler() + + w := do(eh, "POST", "/api/v1/auth/passkey/login/finish", + `{"email":"player@example.net","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader) + + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" { + t.Fatalf("clone finish: code = %d body %s, want 400 passkey_login_invalid (opaque refusal)", w.Code, w.Body.String()) + } + if len(repo.sessions) != 0 { + t.Errorf("clone refusal must mint no session, got %d", len(repo.sessions)) + } + if len(w.Result().Cookies()) != 0 { + t.Errorf("clone refusal must set no session cookie, got %v", w.Result().Cookies()) + } + if got := repo.passkeyCreds["row1"]; got.SignCount != 0 || got.LastUsedAt != nil { + t.Errorf("clone refusal must not advance/stamp the credential, got SignCount=%d LastUsedAt=%v", got.SignCount, got.LastUsedAt) + } + if n := len(repo.audits); n != 1 || repo.audits[0].Action != "auth.passkey_clone_rejected" { + t.Fatalf("want exactly 1 auth.passkey_clone_rejected audit, got %+v", repo.audits) + } + if repo.audits[0].Actor != "player" { + t.Errorf("clone audit actor = %q, want player (the resolved account)", repo.audits[0].Actor) + } +} diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 2525afe..526ce2c 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -1150,6 +1150,18 @@ func (p *PGRepo) PasskeyCredentialsForUser(ctx context.Context, userID string) ( return out, rows.Err() } +// AdvanceCredentialSignCount records a successful assertion on the passkey identified by +// credentialID: it advances the stored signature counter to newSignCount and stamps +// last_used_at. credential_id is UNIQUE so exactly one row is touched; a missing row (the +// credential was unbound mid-ceremony) affects zero rows and is a successful no-op, never an +// error — the assertion is already cryptographically complete by the time this runs. +func (p *PGRepo) AdvanceCredentialSignCount(ctx context.Context, credentialID string, newSignCount uint32, usedAt time.Time) error { + _, err := p.db.ExecContext(ctx, + `UPDATE webauthn_credentials SET sign_count = $2, last_used_at = $3 WHERE credential_id = $1`, + credentialID, int64(newSignCount), usedAt) + return err +} + // DeletePasskeyCredential removes the passkey row id, scoped to userID so a caller can // only unbind their OWN credential. No matching (user, id) row → ErrNotFound via a zero // RowsAffected, so a stale or cross-user id cannot silently no-op as success. diff --git a/internal/api/repo.go b/internal/api/repo.go index cde1d2b..78fd001 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -392,6 +392,13 @@ type Repo interface { // unbinding leaves a re-enrollable credential. Removing zero rows is success, not an // error — an account with no passkeys is the intended post-condition either way. DeleteAllPasskeyCredentialsForUser(ctx context.Context, userID string) error + // AdvanceCredentialSignCount records a successful assertion on the passkey identified by + // credentialID (base64url): it sets the stored signature counter to newSignCount and stamps + // last_used_at. credential_id is UNIQUE, so exactly one row is updated; a missing row (the + // credential was unbound mid-ceremony) is a successful no-op, never an error. The login doors + // call it only after clone policy allows the assertion, so for a counter-keeping authenticator + // the stored counter only ever moves forward — the baseline a later regression is judged against. + AdvanceCredentialSignCount(ctx context.Context, credentialID string, newSignCount uint32, usedAt time.Time) error // ---- player game-login: username-collision reclaim (spec §B3) ---- diff --git a/internal/passkey/verifier.go b/internal/passkey/verifier.go index dd3ed3b..a665adf 100644 --- a/internal/passkey/verifier.go +++ b/internal/passkey/verifier.go @@ -192,10 +192,11 @@ func (v *Verifier) BeginLogin(user api.PasskeyUser) (json.RawMessage, []byte, er // reported. go-webauthn checks the challenge, RP id, and origin against server-held values, // that the asserted credential id is one the user actually holds (it returns // protocol.ErrorUnknownCredential otherwise), and the signature against the stored COSE -// public key. It does NOT decide clone/regression policy here: the returned SignCount is -// the raw ceremony fact, and the handler — which holds the previously-stored counter — -// decides whether a non-increase is a cloned-authenticator signal. The verified credential -// id is returned base64url so the handler can look up the exact row to update. +// public key, and runs go-webauthn's UpdateCounter so a signature counter that fails to +// advance past the stored value raises CloneWarning. It does NOT decide clone policy here: +// the returned SignCount and CloneWarning are raw ceremony facts, and the handler — the one +// consumer, holding the stored counter — decides (it refuses, fail-closed). The verified +// credential id is returned base64url so the handler can look up the exact row to update. func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, assertion io.Reader) (api.VerifiedAssertion, error) { var session webauthn.SessionData if err := json.Unmarshal(sessionData, &session); err != nil { @@ -212,6 +213,7 @@ func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, asserti return api.VerifiedAssertion{ CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID), SignCount: cred.Authenticator.SignCount, + CloneWarning: cred.Authenticator.CloneWarning, }, nil } @@ -274,6 +276,7 @@ func (v *Verifier) FinishDiscoverableLogin(resolveUser func(userHandle []byte) ( return api.VerifiedAssertion{ CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID), SignCount: cred.Authenticator.SignCount, + CloneWarning: cred.Authenticator.CloneWarning, }, nil } diff --git a/internal/passkey/verifier_test.go b/internal/passkey/verifier_test.go index 90dd226..97225d0 100644 --- a/internal/passkey/verifier_test.go +++ b/internal/passkey/verifier_test.go @@ -480,6 +480,79 @@ func TestDiscoverableLoginUnboundCredentialRejected(t *testing.T) { } } +// TestLoginCloneWarningSurfaced proves the adapter SURFACES go-webauthn's clone verdict (task #40 +// item 5) on the username-first door: when the authenticator presents a signature counter at or +// below the stored value, go-webauthn raises CloneWarning but does NOT itself reject (the counter +// is advisory; the RP decides). The adapter must carry that verdict out in VerifiedAssertion so +// the handler can fail closed — without this the handler would have nothing to key clone policy +// on. Note the assertion still VERIFIES (err is nil): a regressed counter is a policy signal, not +// a broken signature. +func TestLoginCloneWarningSurfaced(t *testing.T) { + v := newTestVerifier(t) + rp := virtualRP() + authenticator := virtualwebauthn.NewAuthenticator() + cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + stored := enrollCredential(t, v, rp, authenticator, cred) + + // The stored counter is AHEAD of what the authenticator will present: a regression, which is + // exactly the cloned-authenticator signal go-webauthn's UpdateCounter raises. + stored.SignCount = 100 + cred.Counter = 50 + + options, sessionData, err := v.BeginLogin(testUser(stored)) + if err != nil { + t.Fatalf("BeginLogin: %v", err) + } + assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options)) + if err != nil { + t.Fatalf("ParseAssertionOptions: %v", err) + } + assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts) + va, err := v.FinishLogin(testUser(stored), sessionData, strings.NewReader(assertionResponse)) + if err != nil { + t.Fatalf("FinishLogin: %v (a counter regression must still VERIFY, only flag CloneWarning)", err) + } + if !va.CloneWarning { + t.Fatal("va.CloneWarning = false, want true (presented counter at/below the stored counter is a clone signal)") + } +} + +// TestDiscoverableLoginCloneWarningSurfaced is the same clone-verdict proof for the usernameless +// door (task #40 item 5): a from-zero assertion whose counter regressed must come back VERIFIED +// but with CloneWarning set, so the discoverable handler refuses it in the one shared place the +// username-first door uses. Chained onto a real enrollment so the assertion is genuine crypto. +func TestDiscoverableLoginCloneWarningSurfaced(t *testing.T) { + v := newTestVerifier(t) + rp := virtualRP() + authenticator := virtualwebauthn.NewAuthenticator() + cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + stored := enrollCredential(t, v, rp, authenticator, cred) + + authenticator.Options.UserHandle = []byte(testUserID) + stored.SignCount = 100 + cred.Counter = 50 + + options, sessionData, err := v.BeginDiscoverableLogin() + if err != nil { + t.Fatalf("BeginDiscoverableLogin: %v", err) + } + assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options)) + if err != nil { + t.Fatalf("ParseAssertionOptions: %v (options=%s)", err, options) + } + assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts) + resolve := func(userHandle []byte) (api.PasskeyUser, error) { + return testUser(stored), nil + } + va, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)) + if err != nil { + t.Fatalf("FinishDiscoverableLogin: %v (a counter regression must still VERIFY, only flag CloneWarning)", err) + } + if !va.CloneWarning { + t.Fatal("va.CloneWarning = false, want true (presented counter at/below the stored counter is a clone signal)") + } +} + // TestEnrollmentRequestsResidentKey pins the ONLY server-side half of the from-zero enabler a // unit test can prove: that enrollment ASKS the browser for a resident (discoverable) key, i.e. // the creation options carry authenticatorSelection.residentKey = "preferred". Whether a real