Skip to content

feat(webauthn)!: refuse synced passkeys by default and restrict models - #207

Merged
Bccorb merged 1 commit into
mainfrom
feat/authenticator-model-policy
Aug 30, 2026
Merged

feat(webauthn)!: refuse synced passkeys by default and restrict models#207
Bccorb merged 1 commit into
mainfrom
feat/authenticator-model-policy

Conversation

@Bccorb

@Bccorb Bccorb commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Closes #170. Requires @seamless-auth/types 0.15.0, already released. Finishes
Session 5 of #155.

Breaking, and worth reading before merging

authenticator_policy.syncedPasskeys defaults to block. Every iCloud
Keychain and Google Password Manager passkey is a multi-device credential
, so
on upgrade a deployment relying on platform passkeys stops enrolling them and
gets 403 { "error": "synced_passkey_not_allowed" }.

AUTHENTICATOR_POLICY={"syncedPasskeys":"allow", ...}

restores the previous behaviour. Existing credentials are unaffected; this
governs new registrations.

The default was chosen deliberately rather than inherited. The public sector
README recorded the design position as "blocked by default, agency may enable"
and separately recorded that it was not implemented. This closes that gap in
the direction the position states.

Eligibility, not current state

The decision is made on backup eligibility, surfaced as
credentialDeviceType: 'multiDevice', not on whether the credential is currently
backed up. A credential that can leave the device is the exposure whether or
not it already has, and judging on current state would let one register while
unsynced and sync a minute later. There is a test named for that distinction.

Two decisions inside the evaluator

An authenticator that declines to identify itself is refused against an allow
list, not admitted.
An all-zero AAGUID means "I will not say what I am".
Admitting it would make the allow list advisory, since anything could opt out of
being checked by staying anonymous.

The deny list is applied before the allow list, so a specific model can be
excluded even when a broad allow list would otherwise admit it.

The footgun, reported at startup

An allow list only means something under attestation: 'direct'. Set one while
attestation is none and nothing can identify itself, so every registration is
refused
. Rather than let that be discovered one failed enrolment at a time, the
server logs it once at boot naming the fix.

Documentation, which is an acceptance criterion here

  • docs/public-sector/README.md said "Confirmed not implemented." It now says
    implemented, and how
  • docs/security-posture.md gains a section on the posture, the eligibility
    reasoning, and the attestation dependency, which is what that README asked for
  • configuration.md and .env.example cover the three new fields

Verification

94 test files, 1043 passed, 1 skipped. Coverage 98.8% lines. typecheck, lint,
format:check clean.

12 unit tests on the evaluator and 5 integration tests through the real endpoint.
Mutation checked: disabling the synced-passkey branch fails 4 of them and nothing
else.

Note on the commit history

The pre-commit hook blocked this twice on unrelated tests, an admin anomalies
assertion and then a registration one. Both pass repeatedly in isolation, the
full sequential suite is green, and neither is in a path this change touches.
That is #190.

authenticator_policy gains syncedPasskeys, defaulting to block, plus
aaguidAllowList and aaguidDenyList.

Breaking: a multi-device credential is synced by a platform password manager, so
its private key exists outside the authenticator that made it, and every iCloud
Keychain and Google Password Manager passkey is one. Deployments relying on
platform passkeys stop enrolling them until they set syncedPasskeys to allow.

This closes the gap between what the product did and the design position it was
documented as holding. The public sector README recorded blocked by default,
agency may enable, and separately recorded that it was not implemented. It is
now implemented, and that README and security-posture.md say so.

Judged on backup eligibility rather than current backup state, because a
credential that can leave the device is the exposure whether or not it already
has, and judging on current state would let one register while unsynced and sync
afterwards.

The AAGUID lists need attestation direct to mean anything. An allow list without
it refuses everything, so that combination is reported at startup rather than
discovered one failed enrolment at a time. An authenticator that declines to
identify itself is refused against an allow list rather than admitted, since
admitting it would make the list advisory.

Refusals are distinguishable and each is recorded as a failed registration.

Closes #170
@Bccorb
Bccorb merged commit 40e17ca into main Aug 30, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No authenticator policy: synced passkeys cannot be blocked and AAGUIDs cannot be restricted

1 participant