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]$/),