Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/brave-wolves-shout.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 53 additions & 0 deletions src/schemas/systemConfig/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ describe('AuthenticatorPolicySchema', () => {
userVerification: 'required',
attestation: 'none',
requireKnownAuthenticator: false,
syncedPasskeys: 'block',
aaguidAllowList: [],
aaguidDenyList: [],
});
});

Expand All @@ -194,6 +197,9 @@ describe('SystemConfigSchema authenticator_policy', () => {
userVerification: 'required',
attestation: 'none',
requireKnownAuthenticator: false,
syncedPasskeys: 'block',
aaguidAllowList: [],
aaguidDenyList: [],
});
});

Expand Down Expand Up @@ -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({}));
});
});
38 changes: 37 additions & 1 deletion src/schemas/systemConfig/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export type AuthenticatorAttachmentPolicy = z.infer<typeof AuthenticatorAttachme
*/
export const AttestationPolicySchema = z.enum(['none', 'direct']);

export const SyncedPasskeyPolicySchema = z.enum(['allow', 'block']);

export type SyncedPasskeyPolicy = z.infer<typeof SyncedPasskeyPolicySchema>;

export type AttestationPolicy = z.infer<typeof AttestationPolicySchema>;

export const UserVerificationPolicySchema = z.enum(['required', 'preferred', 'discouraged']);
Expand Down Expand Up @@ -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<typeof AuthenticatorPolicySchema>;
Expand All @@ -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]$/);
Expand All @@ -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]$/),

Expand Down