Store FIDO information about the registered keys to send the proper keys when authentication

This commit is contained in:
Adrià Casajús
2026-03-19 17:39:37 +00:00
committed by Adrià Casajús
parent 7a92b60231
commit 8db564963a
6 changed files with 107 additions and 8 deletions
+30 -7
View File
@@ -27,6 +27,23 @@ from app.models import User, Fido, MfaBrowser
from app.utils import sanitize_next_url
def _backfill_fido_metadata(fido_key: Fido, sk_assertion: dict) -> None:
"""Backfill credential metadata that was not stored at registration time."""
changed = False
if fido_key.credential_type is None:
credential_type = sk_assertion.get("type")
if credential_type:
fido_key.credential_type = credential_type
changed = True
if fido_key.authenticator_attachment is None:
authenticator_attachment = sk_assertion.get("authenticatorAttachment")
if authenticator_attachment:
fido_key.authenticator_attachment = authenticator_attachment
changed = True
if changed:
LOG.d(f"Backfilled metadata for fido credential_id={fido_key.credential_id}")
class FidoTokenForm(FlaskForm):
sk_assertion = HiddenField("sk_assertion", validators=[validators.DataRequired()])
remember = BooleanField(
@@ -105,6 +122,7 @@ def fido():
auto_activate = False
else:
user.fido_sign_count = new_sign_count
_backfill_fido_metadata(fido_key, sk_assertion)
Session.commit()
del session[MFA_USER_ID]
@@ -155,13 +173,18 @@ def fido():
webauthn_users, challenge
)
webauthn_assertion_options = webauthn_assertion_options.assertion_dict
try:
# HACK: We need to upgrade to webauthn > 1 so it can support specifying the transports
for credential in webauthn_assertion_options["allowCredentials"]:
del credential["transports"]
except KeyError:
# Should never happen but...
pass
# Inject stored transports per credential, falling back to removing the field
# if none are stored (keys registered before metadata collection).
fido_by_credential_id = {fido.credential_id: fido for fido in fidos}
for credential in webauthn_assertion_options.get("allowCredentials", []):
fido = fido_by_credential_id.get(credential.get("id"))
if fido and fido.transports:
try:
credential["transports"] = json.loads(fido.transports)
except Exception:
del credential["transports"]
else:
credential.pop("transports", None)
return render_template(
"auth/fido.html",
+32
View File
@@ -1,7 +1,9 @@
import json
import secrets
import struct
import uuid
import cbor2
import webauthn
from flask import render_template, flash, redirect, url_for, session
from flask_login import login_required, current_user
@@ -17,6 +19,31 @@ from app.models import Fido, RecoveryCode
from app.user_settings import regenerate_user_alternative_id
def _extract_aaguid(att_obj_b64: str) -> str | None:
"""Extract the AAGUID from the attestation object's authData (bytes 37-53)."""
try:
# webauthn.py uses _webauthn_b64_decode which handles urlsafe base64
padding = 4 - len(att_obj_b64) % 4
if padding != 4:
att_obj_b64 += "=" * padding
import base64
raw = base64.urlsafe_b64decode(att_obj_b64)
att_obj = cbor2.loads(raw)
auth_data = att_obj.get("authData", b"")
if len(auth_data) < 53:
return None
flags = struct.unpack("!B", auth_data[32:33])[0]
# AT flag (bit 6) indicates attested credential data is present
if not (flags & 0x40):
return None
aaguid_bytes = auth_data[37:53]
return str(uuid.UUID(bytes=aaguid_bytes))
except Exception as e:
LOG.w(f"Failed to extract AAGUID: {e}")
return None
class FidoTokenForm(FlaskForm):
key_name = StringField("key_name", validators=[validators.DataRequired()])
sk_assertion = HiddenField("sk_assertion", validators=[validators.DataRequired()])
@@ -64,6 +91,7 @@ def fido_setup():
current_user.fido_uuid = fido_uuid
Session.flush()
transports = sk_assertion.get("transports")
Fido.create(
credential_id=str(fido_credential.credential_id, "utf-8"),
uuid=fido_uuid,
@@ -71,6 +99,10 @@ def fido_setup():
sign_count=fido_credential.sign_count,
name=fido_token_form.key_name.data,
user_id=current_user.id,
credential_type=sk_assertion.get("type"),
authenticator_attachment=sk_assertion.get("authenticatorAttachment"),
transports=transports if isinstance(transports, list) else None,
aaguid=_extract_aaguid(sk_assertion.get("attObj", "")),
)
regenerate_user_alternative_id(current_user)
Session.commit()
+7
View File
@@ -361,6 +361,13 @@ class Fido(Base, ModelMixin):
sign_count = sa.Column(sa.BigInteger(), nullable=False)
name = sa.Column(sa.String(128), nullable=False, unique=False)
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True)
# Credential metadata for debugging and proper authentication routing
credential_type = sa.Column(sa.String(32), nullable=True)
authenticator_attachment = sa.Column(sa.String(32), nullable=True)
transports = sa.Column(sa.JSON(), nullable=True) # JSON array, e.g. ["usb","nfc"]
aaguid = sa.Column(
sa.String(36), nullable=True
) # UUID format, identifies device model
__table_args__ = (sa.Index("ix_fido_user_id", "user_id"),)
@@ -0,0 +1,30 @@
"""Add credential metadata columns to fido table
Revision ID: 4a9f8c2e1b3d
Revises: 3ee37864eb67
Create Date: 2026-03-19 09:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4a9f8c2e1b3d'
down_revision = '3ee37864eb67'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('fido', sa.Column('credential_type', sa.String(length=32), nullable=True))
op.add_column('fido', sa.Column('authenticator_attachment', sa.String(length=32), nullable=True))
op.add_column('fido', sa.Column('transports', sa.Text(), nullable=True))
op.add_column('fido', sa.Column('aaguid', sa.String(length=36), nullable=True))
def downgrade():
op.drop_column('fido', 'aaguid')
op.drop_column('fido', 'transports')
op.drop_column('fido', 'authenticator_attachment')
op.drop_column('fido', 'credential_type')
+7
View File
@@ -104,6 +104,10 @@ const transformNewAssertionForServer = (newAssertion) => {
const registrationClientExtensions = newAssertion.getClientExtensionResults();
const transports = (typeof newAssertion.response.getTransports === 'function')
? newAssertion.response.getTransports()
: [];
return {
id: newAssertion.id,
rawId: b64enc(rawId),
@@ -111,6 +115,8 @@ const transformNewAssertionForServer = (newAssertion) => {
attObj: b64enc(attObj),
clientData: b64enc(clientDataJSON),
registrationClientExtensions: JSON.stringify(registrationClientExtensions),
authenticatorAttachment: newAssertion.authenticatorAttachment || null,
transports: transports,
};
};
@@ -134,5 +140,6 @@ const transformAssertionForServer = (newAssertion) => {
clientData: b64RawEnc(clientDataJSON),
signature: hexEncode(sig),
assertionClientExtensions: JSON.stringify(assertionClientExtensions),
authenticatorAttachment: newAssertion.authenticatorAttachment || null,
};
};
+1 -1
View File
@@ -31,6 +31,7 @@
{{ fido_token_form.sk_assertion(class="form-control", placeholder="") }}
<div class="text-center mt-3">
<button id="btnVerifyKey"
type="button"
class="btn btn-lg btn-danger"
onclick="verifyAdminKey();">Use your security key</button>
</div>
@@ -58,7 +59,6 @@
}
const skAssertion = transformAssertionForServer(assertion);
skAssertion.authenticatorAttachment = assertion.authenticatorAttachment || null;
$('#sk_assertion').val(JSON.stringify(skAssertion));
$('#formVerifyKey').submit();
}