From 52032243ca9098ec91625b287dd92d2ba1485702 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 30 Aug 2026 08:01:38 -0400 Subject: [PATCH] feat(config): let a deployment refuse synced passkeys and restrict models AuthenticatorPolicySchema gains syncedPasskeys, defaulting to block, plus aaguidAllowList and aaguidDenyList. A multi-device credential is synced by a platform password manager, so its private key exists somewhere outside the authenticator that created it. That is what a consumer wants and what an organisation issuing its own authenticators does not. Blocking is the default, matching the stated design position, and a deployment that wants platform passkeys opts in. 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. The AAGUID lists need attestation direct to mean anything, since an authenticator never asked to identify itself reports no usable AAGUID. The whole-object default for authenticator_policy is now derived from the field defaults rather than restated, so the two cannot drift. Refs fells-code/seamless-auth-api#170 --- .changeset/brave-wolves-shout.md | 29 ++++++++++++++ src/schemas/systemConfig/schema.test.ts | 53 +++++++++++++++++++++++++ src/schemas/systemConfig/schema.ts | 38 +++++++++++++++++- 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 .changeset/brave-wolves-shout.md diff --git a/.changeset/brave-wolves-shout.md b/.changeset/brave-wolves-shout.md new file mode 100644 index 0000000..369d529 --- /dev/null +++ b/.changeset/brave-wolves-shout.md @@ -0,0 +1,29 @@ +--- +'@seamless-auth/types': minor +--- + +Let a deployment refuse synced passkeys and restrict authenticator models. + +`AuthenticatorPolicySchema` gains three fields: + +- `syncedPasskeys`, `allow` or `block`, **defaulting to `block`** +- `aaguidAllowList`, empty by default +- `aaguidDenyList`, empty by default + +**`syncedPasskeys` defaults to `block`, which is a behaviour change.** A +multi-device credential is synced by a platform password manager, so its private +key exists somewhere outside the authenticator that created it. That is what a +consumer wants and what an organisation issuing its own authenticators does not, +and this package now takes the stricter position by default. A deployment that +wants platform passkeys sets `syncedPasskeys: 'allow'`. + +The judgement is made on backup eligibility rather than current backup state: a +credential that _can_ leave the device is the exposure, whether or not it +already has. + +The AAGUID lists restrict which authenticator models may register. They need +`attestation: 'direct'` to mean anything, because an authenticator that was never +asked to identify itself reports no usable AAGUID. + +The whole-object default for `authenticator_policy` is now derived from the field +defaults rather than restated alongside them, so the two cannot drift. diff --git a/src/schemas/systemConfig/schema.test.ts b/src/schemas/systemConfig/schema.test.ts index c406d84..a51a737 100644 --- a/src/schemas/systemConfig/schema.test.ts +++ b/src/schemas/systemConfig/schema.test.ts @@ -171,6 +171,9 @@ describe('AuthenticatorPolicySchema', () => { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'block', + aaguidAllowList: [], + aaguidDenyList: [], }); }); @@ -194,6 +197,9 @@ describe('SystemConfigSchema authenticator_policy', () => { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'block', + aaguidAllowList: [], + aaguidDenyList: [], }); }); @@ -296,3 +302,50 @@ describe('AuthenticatorPolicySchema attestation', () => { expect(parsed.requireKnownAuthenticator).toBe(true); }); }); + +describe('AuthenticatorPolicySchema synced passkeys', () => { + it('blocks credentials that can leave the device by default', () => { + expect(AuthenticatorPolicySchema.parse({}).syncedPasskeys).toBe('block'); + }); + + it('can be relaxed for a consumer deployment', () => { + expect(AuthenticatorPolicySchema.parse({ syncedPasskeys: 'allow' }).syncedPasskeys).toBe( + 'allow', + ); + }); + + it('rejects anything outside that choice', () => { + expect(AuthenticatorPolicySchema.safeParse({ syncedPasskeys: 'sometimes' }).success).toBe( + false, + ); + }); +}); + +describe('AuthenticatorPolicySchema authenticator lists', () => { + it('restricts nothing by default', () => { + const parsed = AuthenticatorPolicySchema.parse({}); + + expect(parsed.aaguidAllowList).toEqual([]); + expect(parsed.aaguidDenyList).toEqual([]); + }); + + it('carries both lists', () => { + const parsed = AuthenticatorPolicySchema.parse({ + aaguidAllowList: ['ee882879-721c-4913-9775-3dfcce97072a'], + aaguidDenyList: ['00000000-0000-0000-0000-000000000000'], + }); + + expect(parsed.aaguidAllowList).toHaveLength(1); + expect(parsed.aaguidDenyList).toHaveLength(1); + }); +}); + +describe('SystemConfigSchema authenticator policy default', () => { + // The whole-object default is derived from the field defaults rather than + // restated, so this asserts the two agree. + it('matches what the policy schema produces on its own', () => { + const fromConfig = SystemConfigSchema.parse(baseConfig).authenticator_policy; + + expect(fromConfig).toEqual(AuthenticatorPolicySchema.parse({})); + }); +}); diff --git a/src/schemas/systemConfig/schema.ts b/src/schemas/systemConfig/schema.ts index 9eb5329..891cca5 100644 --- a/src/schemas/systemConfig/schema.ts +++ b/src/schemas/systemConfig/schema.ts @@ -79,6 +79,10 @@ export type AuthenticatorAttachmentPolicy = z.infer; + export type AttestationPolicy = z.infer; export const UserVerificationPolicySchema = z.enum(['required', 'preferred', 'discouraged']); @@ -120,6 +124,31 @@ export const AuthenticatorPolicySchema = z.object({ * authenticator that was never asked to identify itself cannot be looked up. */ requireKnownAuthenticator: z.boolean().default(false), + /** + * Whether a credential that can leave the device it was created on may be + * registered. + * + * A multi-device credential is synced by a platform password manager, so the + * private key exists somewhere outside the authenticator that made it. That is + * exactly what a consumer wants and exactly what an organisation issuing its + * own authenticators does not. + * + * Judged on backup eligibility rather than current backup state: a credential + * that *can* sync is the exposure, whether or not it has yet. + */ + syncedPasskeys: SyncedPasskeyPolicySchema.default('block'), + /** + * Authenticator models that may register, by AAGUID. + * + * Empty, the default, means no restriction. A non-empty list admits only those + * models, which is how an organisation limits enrolment to the keys it issues + * or to certified hardware. Requires `attestation: 'direct'` to be meaningful, + * since an authenticator that was never asked to identify itself reports no + * usable AAGUID. + */ + aaguidAllowList: z.array(z.string()).default([]), + /** Authenticator models that may not register, by AAGUID. Applied before the allow list. */ + aaguidDenyList: z.array(z.string()).default([]), }); export type AuthenticatorPolicy = z.infer; @@ -129,6 +158,9 @@ export const DefaultAuthenticatorPolicy = { userVerification: 'required', attestation: 'none', requireKnownAuthenticator: false, + syncedPasskeys: 'block', + aaguidAllowList: [], + aaguidDenyList: [], } as const; const DurationSchema = z.string().regex(/^\d+[smhd]$/); @@ -141,7 +173,11 @@ export const SystemConfigSchema = z.object({ passkey_login_fallback_enabled: z.boolean(), oauth_providers: z.array(OAuthProviderConfigSchema).default([]), lockout_policy: LockoutPolicySchema.default(DefaultLockoutPolicy), - authenticator_policy: AuthenticatorPolicySchema.default(DefaultAuthenticatorPolicy), + // Derived from the field defaults rather than restated, so the two cannot drift. + // DefaultAuthenticatorPolicy stays exported for consumers that need the shape. + authenticator_policy: AuthenticatorPolicySchema.default(() => + AuthenticatorPolicySchema.parse({}), + ), access_token_ttl: z.string().regex(/^\d+[smhd]$/),