app/aws/PasskeyDB.scala (195 lines of code) (raw):
package aws
import com.gu.googleauth.UserIdentity
import com.webauthn4j.converter.AttestedCredentialDataConverter
import com.webauthn4j.converter.util.ObjectConverter
import com.webauthn4j.credential.{CredentialRecord, CredentialRecordImpl}
import com.webauthn4j.data.AuthenticationData
import com.webauthn4j.data.attestation.statement.NoneAttestationStatement
import com.webauthn4j.data.extension.authenticator.{
AuthenticationExtensionsAuthenticatorOutputs,
RegistrationExtensionAuthenticatorOutput
}
import com.webauthn4j.util.Base64UrlUtil
import models.JanusException
import play.api.http.Status.INTERNAL_SERVER_ERROR
import software.amazon.awssdk.services.dynamodb.DynamoDbClient
import software.amazon.awssdk.services.dynamodb.model._
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Try}
object PasskeyDB {
private[aws] val tableName = "Passkeys"
private val objConverter = new ObjectConverter()
private val credentialDataConverter = new AttestedCredentialDataConverter(
objConverter
)
/** See
* [[https://webauthn4j.github.io/webauthn4j/en/#credentialrecord-serialization-and-deserialization]]
* for serialisation details.
*/
def toDynamoItem(
user: UserIdentity,
credentialRecord: CredentialRecord
): Map[String, AttributeValue] =
Map(
"username" -> AttributeValue.fromS(user.username),
"credentialId" -> AttributeValue.fromS(
Base64UrlUtil.encodeToString(
credentialRecord.getAttestedCredentialData.getCredentialId
)
),
"credential" -> AttributeValue.fromS(
Base64UrlUtil.encodeToString(
credentialDataConverter.convert(
credentialRecord.getAttestedCredentialData
)
)
),
"attestationStatement" -> AttributeValue.fromS(
Base64UrlUtil.encodeToString(
objConverter.getCborConverter.writeValueAsBytes(
credentialRecord.getAttestationStatement
)
)
),
"authenticatorExtensions" -> AttributeValue.fromS(
Base64UrlUtil.encodeToString(
objConverter.getCborConverter.writeValueAsBytes(
credentialRecord.getAuthenticatorExtensions
)
)
),
// see https://www.w3.org/TR/webauthn-1/#sign-counter
"authCounter" -> AttributeValue.fromN("0")
)
def insert(
user: UserIdentity,
credentialRecord: CredentialRecord
)(implicit dynamoDB: DynamoDbClient): Try[Unit] = Try {
val item = toDynamoItem(user, credentialRecord)
val request =
PutItemRequest.builder().tableName(tableName).item(item.asJava).build()
dynamoDB.putItem(request)
()
}.recoverWith(exception =>
Failure(
JanusException(
userMessage = "Failed to store passkey",
engineerMessage =
s"Failed to store credential for user ${user.username}: ${exception.getMessage}",
httpCode = INTERNAL_SERVER_ERROR,
causedBy = Some(exception)
)
)
)
def loadCredential(
user: UserIdentity,
credentialId: Array[Byte]
)(implicit dynamoDB: DynamoDbClient): Try[GetItemResponse] = {
Try {
val key = Map(
"username" -> AttributeValue.fromS(user.username),
"credentialId" -> AttributeValue.fromS(
Base64UrlUtil.encodeToString(credentialId)
)
)
val request =
GetItemRequest.builder().tableName(tableName).key(key.asJava).build()
dynamoDB.getItem(request)
}.recoverWith(err =>
Failure(
JanusException(
userMessage = "Failed to find registered passkey",
engineerMessage =
s"Failed to load credential for user ${user.username}: ${err.getMessage}",
httpCode = INTERNAL_SERVER_ERROR,
causedBy = Some(err)
)
)
)
}
def extractCredential(
response: GetItemResponse,
user: UserIdentity
): Try[CredentialRecord] = {
if (response.hasItem) {
Try {
val item = response.item()
val attestationStmt = objConverter.getCborConverter.readValue(
Base64UrlUtil.decode(item.get("attestationStatement").s()),
classOf[NoneAttestationStatement]
)
val counter = item.get("authCounter").n().toLong
val credentialData = credentialDataConverter.convert(
Base64UrlUtil.decode(item.get("credential").s())
)
val authExts = objConverter.getCborConverter.readValue(
Base64UrlUtil.decode(item.get("authenticatorExtensions").s()),
classOf[AuthenticationExtensionsAuthenticatorOutputs[
RegistrationExtensionAuthenticatorOutput
]]
)
new CredentialRecordImpl(
attestationStmt,
null,
null,
null,
counter,
credentialData,
authExts,
null,
null,
null
)
}.recoverWith(err =>
Failure(
JanusException(
userMessage = "Invalid registered passkey",
engineerMessage =
s"Failed to extract credential data for user ${user.username}: ${err.getMessage}",
httpCode = INTERNAL_SERVER_ERROR,
causedBy = Some(err)
)
)
)
} else {
Failure(
JanusException(
userMessage = "Failed to find registered passkey",
engineerMessage =
s"Credential data not found for user ${user.username}: GetItem response: $response",
httpCode = INTERNAL_SERVER_ERROR,
causedBy = None
)
)
}
}
/** The device hosting the passkey keeps a count of how many times the passkey
* has been requested. As part of the verification process, this count is
* compared with the request count stored in the DB.
*
* See https://www.w3.org/TR/webauthn-1/#sign-counter
*/
def updateCounter(user: UserIdentity, authData: AuthenticationData)(implicit
dynamoDB: DynamoDbClient
): Try[Unit] = Try {
val key = Map(
"username" -> AttributeValue.fromS(user.username),
"credentialId" -> AttributeValue.fromS(
Base64UrlUtil.encodeToString(authData.getCredentialId)
)
)
val update =
Map(
":counterValue" -> AttributeValue.fromN(
String.valueOf(authData.getAuthenticatorData.getSignCount)
)
)
val request = UpdateItemRequest.builder
.tableName(tableName)
.key(key.asJava)
.updateExpression("SET authCounter = :counterValue")
.expressionAttributeValues(update.asJava)
.build()
dynamoDB.updateItem(request)
()
}.recoverWith(err =>
Failure(
JanusException(
userMessage = "Failed to update authentication counter",
engineerMessage =
s"Failed to update authentication counter for user ${user.username}: ${err.getMessage}",
httpCode = INTERNAL_SERVER_ERROR,
causedBy = Some(err)
)
)
)
}