diff --git a/CONFIG.md b/CONFIG.md
index 2fcb84df..b6bcecff 100644
--- a/CONFIG.md
+++ b/CONFIG.md
@@ -178,6 +178,7 @@ Provides passwordless login using biometrics, security keys, or device authentic
- **Relying Party ID (`user.webauthn.rpId`)**: For development, use `localhost`. For production, use your domain (e.g., `example.com`). Defaults to `localhost`.
- **Relying Party Name (`user.webauthn.rpName`)**: The display name.
- **Allowed Origins (`user.webauthn.allowedOrigins`)**: Comma-separated list of allowed origins. Defaults to `https://localhost:8443`.
+- **Registration notification (`user.webauthn.notifyOnRegistration`)**: Email the account owner when a passkey is registered on their account. Defaults to `true`. Enrolling a passkey grants a durable new way into the account that survives a password change, since session invalidation ends sessions rather than credentials, so an enrollment the owner did not perform is worth surfacing. A `PasskeyRegistration` audit event is recorded either way. Set to `false` only if your application sends its own equivalent notification.
**Development Example:**
```properties
diff --git a/src/main/java/com/digitalsanctuary/spring/user/event/WebAuthnCredentialRegisteredEvent.java b/src/main/java/com/digitalsanctuary/spring/user/event/WebAuthnCredentialRegisteredEvent.java
new file mode 100644
index 00000000..1b75a511
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/event/WebAuthnCredentialRegisteredEvent.java
@@ -0,0 +1,52 @@
+package com.digitalsanctuary.spring.user.event;
+
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import lombok.Getter;
+import lombok.ToString;
+import org.springframework.context.ApplicationEvent;
+
+/**
+ * Published when a user registers a new WebAuthn credential (passkey).
+ *
+ *
+ * Enrolling a passkey grants a durable new way into the account, and it survives a password change, since session
+ * invalidation ends sessions rather than credentials. Spring Security owns the endpoint that performs it
+ * ({@code POST /webauthn/register}), so the framework observes enrollment where it writes through: the JPA
+ * {@code UserCredentialRepository}. That catches every enrollment regardless of which endpoint triggered it.
+ *
+ *
+ *
+ * The event fires only for a genuinely new credential. Spring Security also saves through the same repository on
+ * every successful assertion, to persist the updated signature count, and that is not a registration.
+ *
+ */
+@Getter
+@ToString(callSuper = false)
+public class WebAuthnCredentialRegisteredEvent extends ApplicationEvent {
+
+ private static final long serialVersionUID = 1L;
+
+ /** The user who registered the credential. */
+ private final transient User user;
+
+ /** The base64url credential id, useful for correlating with the audit log. */
+ private final String credentialId;
+
+ /** The user-supplied label for the credential, or {@code "Passkey"} when none was given. */
+ private final String label;
+
+ /**
+ * Creates the event.
+ *
+ * @param source the component publishing the event
+ * @param user the user who registered the credential
+ * @param credentialId the base64url credential id
+ * @param label the credential label
+ */
+ public WebAuthnCredentialRegisteredEvent(Object source, User user, String credentialId, String label) {
+ super(source);
+ this.user = user;
+ this.credentialId = credentialId;
+ this.label = label;
+ }
+}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListener.java b/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListener.java
new file mode 100644
index 00000000..c7c78625
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListener.java
@@ -0,0 +1,57 @@
+package com.digitalsanctuary.spring.user.listener;
+
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.ApplicationListener;
+import org.springframework.stereotype.Component;
+import com.digitalsanctuary.spring.user.audit.AuditEvent;
+import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent;
+import com.digitalsanctuary.spring.user.security.WebAuthnConfigProperties;
+import com.digitalsanctuary.spring.user.service.UserEmailService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Records and announces passkey enrollment.
+ *
+ *
+ * A newly enrolled credential is a durable new way into the account, and it outlives a password change, since
+ * session invalidation ends sessions rather than credentials. An attacker who reaches an authenticated session can
+ * therefore leave themselves a way back in. Preventing that is the job of step-up
+ * ({@code user.security.stepUp.enabled}); this listener is the detective half, so the enrollment is at least
+ * recorded and visible to the account owner whether or not step-up is switched on.
+ *
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WebAuthnCredentialRegistrationListener implements ApplicationListener {
+
+ private final UserEmailService userEmailService;
+ private final ApplicationEventPublisher eventPublisher;
+ private final WebAuthnConfigProperties webAuthnConfigProperties;
+
+ /**
+ * Audits the enrollment and, unless disabled, emails the account owner.
+ *
+ * @param event the registration event
+ */
+ @Override
+ public void onApplicationEvent(WebAuthnCredentialRegisteredEvent event) {
+ // Audit first and unconditionally: the notification is a courtesy the operator can switch off, and a mail
+ // outage must not cost us the security-relevant record of the enrollment.
+ eventPublisher.publishEvent(AuditEvent.builder().source(this).user(event.getUser())
+ .action("PasskeyRegistration").actionStatus("Success")
+ .message("Passkey registered: " + event.getLabel()).build());
+
+ if (!webAuthnConfigProperties.isNotifyOnRegistration()) {
+ return;
+ }
+
+ try {
+ userEmailService.sendPasskeyRegisteredNotification(event.getUser(), event.getLabel());
+ } catch (RuntimeException e) {
+ // Never let a mail failure propagate into the registration flow, which has already committed.
+ log.error("Failed to send passkey registration notification to user {}", event.getUser().getId(), e);
+ }
+ }
+}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java
index e30a9780..4488017f 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java
@@ -30,4 +30,11 @@ public class WebAuthnConfigProperties {
* Whether Passkey support is enabled.
*/
private boolean enabled = false;
+
+ /**
+ * Whether to email the account owner when a passkey is registered on their account. Enrolling a passkey grants a
+ * durable new way into the account that survives a password change, so the owner is told by default. Set to
+ * {@code false} only if your application sends its own equivalent notification.
+ */
+ private boolean notifyOnRegistration = true;
}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java
index 00c04c9a..5a7eb9bb 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java
@@ -2,9 +2,11 @@
import java.util.Base64;
import java.util.List;
+import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
@@ -15,6 +17,7 @@
import org.springframework.security.web.webauthn.api.PublicKeyCredentialType;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import org.springframework.transaction.annotation.Transactional;
+import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent;
import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential;
import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity;
import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialRepository;
@@ -47,13 +50,14 @@ public class WebAuthnRepositoryConfig {
*
* @param credentialRepository JPA repository for WebAuthn credentials
* @param userEntityRepository JPA repository for WebAuthn user entities
+ * @param eventPublisher publishes {@link WebAuthnCredentialRegisteredEvent} when a new credential is enrolled
* @return the UserCredentialRepository instance
*/
@Bean
public UserCredentialRepository userCredentialRepository(WebAuthnCredentialRepository credentialRepository,
- WebAuthnUserEntityRepository userEntityRepository) {
+ WebAuthnUserEntityRepository userEntityRepository, ApplicationEventPublisher eventPublisher) {
log.info("Initializing JPA-backed WebAuthn UserCredentialRepository");
- return new JpaUserCredentialRepository(credentialRepository, userEntityRepository);
+ return new JpaUserCredentialRepository(credentialRepository, userEntityRepository, eventPublisher);
}
/**
@@ -65,11 +69,13 @@ static class JpaUserCredentialRepository implements UserCredentialRepository {
private final WebAuthnCredentialRepository credentialRepository;
private final WebAuthnUserEntityRepository userEntityRepository;
+ private final ApplicationEventPublisher eventPublisher;
JpaUserCredentialRepository(WebAuthnCredentialRepository credentialRepository,
- WebAuthnUserEntityRepository userEntityRepository) {
+ WebAuthnUserEntityRepository userEntityRepository, ApplicationEventPublisher eventPublisher) {
this.credentialRepository = credentialRepository;
this.userEntityRepository = userEntityRepository;
+ this.eventPublisher = eventPublisher;
}
@Override
@@ -77,7 +83,11 @@ static class JpaUserCredentialRepository implements UserCredentialRepository {
public void save(CredentialRecord record) {
String credIdStr = toBase64Url(record.getCredentialId().getBytes());
- WebAuthnCredential entity = credentialRepository.findById(credIdStr).orElseGet(WebAuthnCredential::new);
+ // Spring Security also saves through here on every successful assertion, to persist the updated
+ // signature count. Only an absent row is a registration; anything else is that update.
+ Optional existing = credentialRepository.findById(credIdStr);
+ boolean newCredential = existing.isEmpty();
+ WebAuthnCredential entity = existing.orElseGet(WebAuthnCredential::new);
entity.setCredentialId(credIdStr);
// Look up the user entity
@@ -104,6 +114,11 @@ public void save(CredentialRecord record) {
entity.setLabel(record.getLabel() != null ? record.getLabel() : "Passkey");
credentialRepository.save(entity);
+
+ if (newCredential) {
+ eventPublisher.publishEvent(new WebAuthnCredentialRegisteredEvent(this, userEntity.getUser(),
+ credIdStr, entity.getLabel()));
+ }
}
@Override
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java
index 2593abd0..6d595062 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java
@@ -175,6 +175,29 @@ public void sendRegistrationVerificationEmail(final Long userId, final String ap
* @return the map
* @throws IllegalArgumentException if appUrl is invalid (for admin-initiated resets)
*/
+ /**
+ * Notifies the account owner that a passkey was registered on their account.
+ *
+ *
+ * Sent for every new credential, however it was enrolled. A passkey the owner did not add is a sign someone
+ * else reached their session, and it would otherwise be invisible until they went looking.
+ *
+ *
+ * @param user the account owner
+ * @param label the label of the newly registered passkey
+ */
+ public void sendPasskeyRegisteredNotification(final User user, final String label) {
+ if (user == null || user.getEmail() == null) {
+ log.warn("UserEmailService.sendPasskeyRegisteredNotification: no recipient, skipping");
+ return;
+ }
+ Map variables = new HashMap<>();
+ variables.put("user", user);
+ variables.put("label", label != null ? label : "Passkey");
+ mailService.sendTemplateMessage(user.getEmail(), "New passkey added to your account", variables,
+ "mail/webauthn-credential-registered.html");
+ }
+
private Map createEmailVariables(final User user, final String appUrl, final String token, final String confirmationPath) {
Map variables = new HashMap<>();
variables.put("token", token);
diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties
index 563f4092..17cb77cc 100644
--- a/src/main/resources/config/dsspringuserconfig.properties
+++ b/src/main/resources/config/dsspringuserconfig.properties
@@ -194,6 +194,9 @@ user.webauthn.enabled=false
user.webauthn.rpId=localhost
user.webauthn.rpName=Spring User Framework
user.webauthn.allowedOrigins=https://localhost:8443
+# Email the account owner when a passkey is registered. A passkey survives a password change, so an
+# unrecognized one is worth telling the owner about. The audit event is recorded regardless of this setting.
+user.webauthn.notifyOnRegistration=true
# MFA (Multi-Factor Authentication) Configuration (disabled by default; opt-in feature)
# When enabled, all authenticated endpoints require all configured factors to be satisfied.
diff --git a/src/main/resources/messages/dsspringusermessages.properties b/src/main/resources/messages/dsspringusermessages.properties
index 1c80803a..1a7b32b8 100644
--- a/src/main/resources/messages/dsspringusermessages.properties
+++ b/src/main/resources/messages/dsspringusermessages.properties
@@ -7,6 +7,9 @@ email.registration-confirmation.intro=Thank you for registering with the Spring
email.registration-confirmation.link-instructions=You’ve successfully registered. To confirm your account, click the link below.
email.registration-confirmation.link-expiration=This link will be valid for 24 hours. If it expires, you can request a new verification email.
+email.passkey-registered.intro=A new passkey ({0}) was just added to your account. If you added it, no action is needed.
+email.passkey-registered.warning=If you did not add this passkey, someone else may have access to your account. A passkey remains valid even after a password change, so change your password and remove the unrecognized passkey from your account settings, then contact support.
+
email.signature=Best regards,
The DigitalSanctuary Team
diff --git a/src/main/resources/templates/mail/webauthn-credential-registered.html b/src/main/resources/templates/mail/webauthn-credential-registered.html
new file mode 100644
index 00000000..63110f7f
--- /dev/null
+++ b/src/main/resources/templates/mail/webauthn-credential-registered.html
@@ -0,0 +1,20 @@
+
+
+
+
+ New passkey added to your account
+
+
+
+
+
+
+
+
+
diff --git a/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListenerTest.java b/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListenerTest.java
new file mode 100644
index 00000000..a8bcaa24
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListenerTest.java
@@ -0,0 +1,92 @@
+package com.digitalsanctuary.spring.user.listener;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.context.ApplicationEventPublisher;
+import com.digitalsanctuary.spring.user.audit.AuditEvent;
+import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.security.WebAuthnConfigProperties;
+import com.digitalsanctuary.spring.user.service.UserEmailService;
+
+/**
+ * A newly enrolled passkey is a durable new way into the account that outlives a password change, so the owner is
+ * told about it and the enrollment is recorded in the audit log.
+ */
+@DisplayName("WebAuthn Credential Registration Listener Tests")
+class WebAuthnCredentialRegistrationListenerTest {
+
+ private UserEmailService userEmailService;
+ private ApplicationEventPublisher eventPublisher;
+ private WebAuthnConfigProperties config;
+ private User user;
+
+ @BeforeEach
+ void setUp() {
+ userEmailService = mock(UserEmailService.class);
+ eventPublisher = mock(ApplicationEventPublisher.class);
+ config = new WebAuthnConfigProperties();
+ user = new User();
+ user.setId(3L);
+ user.setEmail("passkey-user@test.com");
+ }
+
+ private WebAuthnCredentialRegisteredEvent event() {
+ return new WebAuthnCredentialRegisteredEvent(this, user, "AQIDBA", "Work Laptop");
+ }
+
+ private WebAuthnCredentialRegistrationListener listener() {
+ return new WebAuthnCredentialRegistrationListener(userEmailService, eventPublisher, config);
+ }
+
+ @Test
+ @DisplayName("should record an audit event when a passkey is registered")
+ void shouldRecordAuditEventWhenPasskeyRegistered() {
+ listener().onApplicationEvent(event());
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(AuditEvent.class);
+ verify(eventPublisher).publishEvent(captor.capture());
+ assertThat(captor.getValue().getAction()).isEqualTo("PasskeyRegistration");
+ assertThat(captor.getValue().getActionStatus()).isEqualTo("Success");
+ assertThat(captor.getValue().getUser()).isEqualTo(user);
+ }
+
+ @Test
+ @DisplayName("should notify the account owner by email when a passkey is registered")
+ void shouldNotifyOwnerWhenPasskeyRegistered() {
+ listener().onApplicationEvent(event());
+
+ verify(userEmailService).sendPasskeyRegisteredNotification(user, "Work Laptop");
+ }
+
+ @Test
+ @DisplayName("should still record the audit event when notification email is disabled")
+ void shouldStillAuditWhenNotificationDisabled() {
+ // The email is a courtesy the operator may not want; the audit trail is not optional.
+ config.setNotifyOnRegistration(false);
+
+ listener().onApplicationEvent(event());
+
+ verify(userEmailService, never()).sendPasskeyRegisteredNotification(any(), any());
+ verify(eventPublisher).publishEvent(any(AuditEvent.class));
+ }
+
+ @Test
+ @DisplayName("should record the audit event even when sending the notification fails")
+ void shouldAuditEvenWhenNotificationFails() {
+ // A mail outage must not lose the security-relevant record of the enrollment.
+ org.mockito.Mockito.doThrow(new RuntimeException("smtp down")).when(userEmailService)
+ .sendPasskeyRegisteredNotification(any(), any());
+
+ listener().onApplicationEvent(event());
+
+ verify(eventPublisher).publishEvent(any(AuditEvent.class));
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnCredentialRegistrationEventTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnCredentialRegistrationEventTest.java
new file mode 100644
index 00000000..b4ec74b6
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnCredentialRegistrationEventTest.java
@@ -0,0 +1,105 @@
+package com.digitalsanctuary.spring.user.security;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.time.Instant;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.security.web.webauthn.api.Bytes;
+import org.springframework.security.web.webauthn.api.CredentialRecord;
+import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord;
+import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCose;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialType;
+import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential;
+import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity;
+import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialRepository;
+import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnUserEntityRepository;
+
+/**
+ * Enrolling a passkey is a credential-altering event the account owner should hear about, and the framework does not
+ * own the endpoint that does it: Spring Security's {@code POST /webauthn/register} writes through
+ * {@code JpaUserCredentialRepository}, which is the framework's. Publishing from there catches every enrollment
+ * regardless of which endpoint triggered it.
+ *
+ * The trap this pins: {@code UserCredentialRepository.save} is also called on every successful assertion, to persist
+ * the updated signature count. Publishing on every save would email the user on each login.
+ *
+ */
+@DisplayName("WebAuthn Credential Registration Event Tests")
+class WebAuthnCredentialRegistrationEventTest {
+
+ private static final String CREDENTIAL_ID_B64 = "AQIDBA";
+
+ private WebAuthnCredentialRepository credentialRepository;
+ private WebAuthnUserEntityRepository userEntityRepository;
+ private ApplicationEventPublisher eventPublisher;
+ private WebAuthnRepositoryConfig.JpaUserCredentialRepository repository;
+ private User user;
+
+ @BeforeEach
+ void setUp() {
+ credentialRepository = mock(WebAuthnCredentialRepository.class);
+ userEntityRepository = mock(WebAuthnUserEntityRepository.class);
+ eventPublisher = mock(ApplicationEventPublisher.class);
+
+ user = new User();
+ user.setId(7L);
+ user.setEmail("passkey-user@test.com");
+
+ WebAuthnUserEntity userEntity = new WebAuthnUserEntity();
+ userEntity.setId("dXNlcg");
+ userEntity.setName(user.getEmail());
+ userEntity.setUser(user);
+ when(userEntityRepository.findById(any())).thenReturn(Optional.of(userEntity));
+
+ repository = new WebAuthnRepositoryConfig.JpaUserCredentialRepository(credentialRepository,
+ userEntityRepository, eventPublisher);
+ }
+
+ @Test
+ @DisplayName("should publish a registration event when the credential is new")
+ void shouldPublishEventWhenCredentialIsNew() {
+ when(credentialRepository.findById(CREDENTIAL_ID_B64)).thenReturn(Optional.empty());
+
+ repository.save(credentialRecord("Work Laptop"));
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(WebAuthnCredentialRegisteredEvent.class);
+ verify(eventPublisher).publishEvent(captor.capture());
+ assertThat(captor.getValue().getUser()).isEqualTo(user);
+ assertThat(captor.getValue().getLabel()).isEqualTo("Work Laptop");
+ }
+
+ @Test
+ @DisplayName("should publish no event when an existing credential is updated")
+ void shouldPublishNoEventWhenCredentialIsUpdated() {
+ // Spring Security calls save() inside authenticate() to persist the signature count. Treating that as a
+ // registration would email the user on every passkey login.
+ WebAuthnCredential existing = new WebAuthnCredential();
+ existing.setCredentialId(CREDENTIAL_ID_B64);
+ when(credentialRepository.findById(CREDENTIAL_ID_B64)).thenReturn(Optional.of(existing));
+
+ repository.save(credentialRecord("Work Laptop"));
+
+ verify(eventPublisher, never()).publishEvent(any(WebAuthnCredentialRegisteredEvent.class));
+ }
+
+ private static CredentialRecord credentialRecord(String label) {
+ return ImmutableCredentialRecord.builder().credentialType(PublicKeyCredentialType.PUBLIC_KEY)
+ .credentialId(new Bytes(new byte[] {1, 2, 3, 4}))
+ .userEntityUserId(new Bytes("user".getBytes()))
+ .publicKey(new ImmutablePublicKeyCose(new byte[] {9, 9}))
+ .signatureCount(0).uvInitialized(true).backupEligible(false).backupState(false)
+ .created(Instant.now()).lastUsed(Instant.now()).label(label).build();
+ }
+}